import assert from 'node:assert/strict'; import Brightness from '../../brightness.js'; export const taskSelection = 'tasks'; export const taskServiceFixtures = Object.freeze([ 'Audio', 'Bluetooth', 'Brightness', 'Clipboard', 'Launcher', 'Media', 'Network', 'Noko', 'Niri', 'Notifications', 'Power', 'Tray', 'Usage', 'Vault', ].map(name => ({ source: `test-fixtures/ui/task-services/${name}.qml`, target: `${name}.qml` }))); export const reusedTaskServiceFixtures = Object.freeze([ { source: 'test-fixtures/ui/services/Privilege.qml', target: 'Privilege.qml' }, { source: 'test-fixtures/ui/services/Resources.qml', target: 'Resources.qml' }, ]); export const taskOverlayFixtures = Object.freeze([...taskServiceFixtures, ...reusedTaskServiceFixtures]); export const taskEntryFixture = Object.freeze({ source: 'test-fixtures/ui/tasks.qml', target: 'tasks-ui-test.qml', replace: Object.freeze({ from: 'import "../.." as N', to: 'import "." as N' }), }); function within(rect, width, height) { return rect && rect.width > 0 && rect.height > 0 && rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= width && rect.y + rect.height <= height; } function center(rect) { return [String(Math.round(rect.x + rect.width / 2)), String(Math.round(rect.y + rect.height / 2))]; } function outsideRect(state) { const card = state.popup.card; const candidates = [ { x: state.width - 14, y: state.height - 14, width: 6, height: 6 }, { x: 8, y: state.height - 14, width: 6, height: 6 }, { x: state.width - 14, y: 40, width: 6, height: 6 }, ]; const result = candidates.find(rect => rect.x + rect.width < card.x || rect.x > card.x + card.width || rect.y + rect.height < card.y || rect.y > card.y + card.height); assert(result, 'mapped output must contain an outside-click target'); assert(within(result, state.width, state.height)); return result; } export async function runTasks({ ipc, snapshot, input, key, click, check, screenshot, run, delay }) { for (const helper of [ipc, snapshot, input, key, click, check, screenshot, run, delay]) assert.equal(typeof helper, 'function', 'tasks runner requires the parent UI helper contract'); function assertGeometry(state, primary, name) { assert(state.width >= 520 && state.height >= 260, 'output is below the production popup minimum'); assert(within(state.popup.card, state.width, state.height), `${name} popup card outside output`); assert(within(state.panel, state.width, state.height), `${name} production panel outside output`); assert(within(primary, state.width, state.height), `${name} primary control outside output`); } function confirmationFits(value) { const region = value.power.confirmation; const action = value.power.poweroff?.rect; return region && action && region.y >= action.y + action.height && region.y + region.height <= value.panel.y + value.panel.height; } async function open(name, primary) { const before = snapshot(); assert(within(before.chips[name].rect, before.width, before.height), `${name} Bar chip outside output`); click(before.chips[name].rect); let previousGeometry = ''; const state = await check(`${name} opens from the real Bar`, value => { if (value.popup.name !== name || value.popup.phase !== 'open' || !value.popup.windowActive || value.layoutFrames < 4 || !primary(value)) return false; const geometry = JSON.stringify([value.popup.card, value.panel, primary(value)]); const settled = geometry === previousGeometry; previousGeometry = geometry; return settled; }); assertGeometry(state, primary(state), name); return state; } let state = await check('task fixture maps the configured output and real Bar', value => value.width > 0 && value.height > 0 && Object.values(value.chips).every(chip => within(chip.rect, value.width, value.height))); state = await check('horizontal clock stays at the far right with widgets before it', value => value.clock && value.clock.x + value.clock.width === value.width - 10 && Object.values(value.chips).every(chip => chip.rect.x + chip.rect.width <= value.clock.x - 12)); await screenshot('tasks-clock-right'); click(state.clock); await check('rightmost clock opens its calendar by pointer', value => value.popup.name === 'clock' && value.popup.phase === 'open' && value.popup.windowActive); key('Escape'); await check('Escape dismisses the calendar opened from the rightmost clock', value => !value.popup.open); assert.equal(state.barEdge.height, 1, 'horizontal Bar accent is a hairline'); assert.equal(state.barEdge.width, state.width); state = await open('clipboard', value => value.clipboard.search?.rect); assert(state.clipboard.search.focused, 'clipboard search must receive initial focus'); input('type', 'needle'); state = await check('clipboard query filters without exposing editable text', value => value.popup.name === 'clipboard' && value.clipboard.queryKnown && value.clipboard.selectedKnown && value.clipboard.visibleRows === 1); click(state.clipboard.copy.rect); state = await check('clipboard copy preserves the active filter', value => value.clipboard.copies === 1 && value.clipboard.queryKnown && value.clipboard.visibleRows === 1); click(state.clipboard.pin.rect); state = await check('clipboard pin preserves query and selected identity', value => value.clipboard.pins === 1 && value.clipboard.queryKnown && value.clipboard.selectedKnown && value.clipboard.visibleRows === 1); ipc('external', 'clipboard-update'); state = await check('external clipboard update remains filtered after copy and pin', value => value.clipboard.externalUpdates === 1 && value.clipboard.queryKnown && value.clipboard.visibleRows === 2); await screenshot('tasks-clipboard-filtered'); input('mousemove', ...center(state.chips.launcher.rect)); await delay(120); state = await check('real Bar hover does not switch the active clipboard task', value => value.chips.launcher.hovered && value.popup.name === 'clipboard' && value.clipboard.queryKnown); click(state.chips.launcher.rect); state = await check('Bar activation switches from clipboard to launcher', value => value.popup.name === 'launcher' && value.popup.phase === 'open' && value.launcher.search?.rect); assertGeometry(state, state.launcher.search.rect, 'launcher'); assert(state.launcher.search.focused, 'launcher search must receive initial focus'); input('type', 'terminal'); state = await check('launcher draft filters without exposing editable text', value => value.launcher.queryKnown && value.launcher.visibleRows === 1); await screenshot('tasks-launcher-draft'); click(state.chips.clipboard.rect); state = await check('switching restores clipboard query and results', value => value.popup.name === 'clipboard' && value.clipboard.queryKnown && value.clipboard.visibleRows === 2); click(state.chips.clipboard.rect); await check('same Bar chip closes clipboard without discarding its draft', value => !value.popup.open && value.popup.lastCloseReason === 'bar-toggle' && value.clipboard.queryKnown); state = await open('clipboard', value => value.clipboard.search?.rect); assert(state.clipboard.queryKnown && state.clipboard.visibleRows === 2, 'clipboard draft must survive close and reopen'); click(state.chips.launcher.rect); state = await check('switching back restores the launcher draft', value => value.popup.name === 'launcher' && value.launcher.queryKnown && value.launcher.visibleRows === 1); click(state.chips.launcher.rect); await check('same Bar chip closes launcher without discarding its draft', value => !value.popup.open && value.popup.lastCloseReason === 'bar-toggle' && value.launcher.queryKnown); state = await open('launcher', value => value.launcher.search?.rect); assert(state.launcher.queryKnown && state.launcher.visibleRows === 1, 'launcher draft must survive close and reopen'); state = await open('audio', value => value.audio.slider?.rect); const slider = state.audio.slider.rect; input('mousemove', String(Math.round(slider.x + slider.width * 0.2)), String(Math.round(slider.y + slider.height / 2))); input('mousedown', '1'); input('mousemove', String(Math.round(slider.x + slider.width * 0.58)), String(Math.round(slider.y + slider.height / 2))); state = await check('audio slider drag writes while retaining the real delegate', value => value.audio.pressed && value.audio.sameSlider && value.audio.writes > 0); const writesDuringDrag = state.audio.writes; ipc('external', 'audio-update'); await check('external audio update does not interrupt the held drag', value => value.audio.pressed && value.audio.sameSlider && value.audio.externalUpdates === 1 && value.audio.writes === writesDuringDrag); input('mousemove', String(Math.round(slider.x + slider.width * 0.82)), String(Math.round(slider.y + slider.height / 2))); input('mouseup', '1'); state = await check('audio drag completes after the external update', value => !value.audio.pressed && value.audio.sameSlider && value.audio.writes > writesDuringDrag && value.audio.volume > 0.8); await screenshot('tasks-audio-drag-update'); ipc('external', 'audio-many'); for (let i = 0; i < 18; i++) { key('Tab'); state = await check(`audio keyboard traversal keeps control visible ${i}`, value => { const f = value.audio.focusedRect, p = value.panel; return f && p && f.y >= p.y && f.y + f.height <= p.y + p.height; }); } assert(state.audio.scrollY > 0, 'long audio list must actually scroll'); const retainedAudioScroll = state.audio.scrollY; await screenshot('tasks-audio-keyboard-scroll'); state = await open('media', value => value.media.play?.rect); assert.equal(snapshot().popup.name, 'media'); assert.equal(state.media.previous.enabled, false); assert.equal(state.media.next.enabled, true); assert.equal(state.media.play.enabled, true); assert.equal(state.media.shuffle.enabled, true); assert.equal(state.media.repeat.enabled, true); click(state.media.next.rect); state = await check('media enabled next action dispatches once by pointer', value => value.media.actions === 1); click(state.media.play.rect); key('space'); state = await check('media play control supports pointer and keyboard activation', value => value.media.actions === 3 && value.media.lastWasPlay); ipc('external', 'media-restrict'); state = await check('media capabilities disable every unsupported transport', value => value.media.restricted && !value.media.previous.enabled && !value.media.next.enabled && !value.media.play.enabled && !value.media.shuffle.enabled && !value.media.repeat.enabled); click(state.media.next.rect); await delay(100); assert.equal(snapshot().media.actions, 3, 'disabled media action must reject pointer activation'); await screenshot('tasks-media-capabilities'); ipc('external', 'media-empty'); state = await check('last player disappearing shrinks media and removes dead controls', value => value.media.emptyState && !value.media.play && value.popup.card.width === 352 && value.popup.card.height === 88); assertGeometry(state, state.media.emptyState, 'empty media'); await screenshot('tasks-media-empty'); key('Escape'); await check('Escape dismisses empty media', value => !value.popup.open); await open('media', value => value.media.emptyState); ipc('external', 'media-populated'); await check('player arrival restores media controls and size without reopening', value => value.media.play && !value.media.emptyState && value.popup.card.height === 404); state = await open('tray', value => value.tray.primary?.rect); click(state.tray.primary.rect); state = await check('tray primary action is keyboard focusable after pointer activation', value => value.tray.primaryCount === 1 && value.tray.primary.focused); key('Return'); state = await check('tray card activates with Enter', value => value.tray.primaryCount === 2); key('Tab'); state = await check('Tab reaches tray secondary action', value => value.tray.secondary.focused); key('space'); state = await check('tray secondary action activates with Space', value => value.tray.secondaryCount === 1); key('Tab'); state = await check('Tab reaches tray menu action', value => value.tray.menu.focused); key('Return'); await check('tray menu action activates with Enter', value => value.tray.menuCount === 1); await screenshot('tasks-tray-keyboard'); ipc('external', 'tray-empty'); state = await check('last tray item disappearing shrinks tray and removes actions', value => value.tray.emptyState && !value.tray.primary && !value.tray.menu && value.popup.card.width === 352 && value.popup.card.height === 88); assertGeometry(state, state.tray.emptyState, 'empty tray'); await screenshot('tasks-tray-empty'); click(outsideRect(state)); await check('outside click dismisses empty tray', value => !value.popup.open && value.popup.lastCloseReason === 'outside'); await open('tray', value => value.tray.emptyState); ipc('external', 'tray-populated'); await check('tray item arrival restores actions and size without reopening', value => value.tray.primary && !value.tray.emptyState && value.popup.card.height === 424); state = await open('power', value => value.power.reboot?.rect); assert.equal(state.popup.card.width, 352); assert.equal(state.popup.card.height, 232); assert.equal(state.power.battery.height, 48); await screenshot('tasks-power-compact'); input('mousemove', ...center(state.power.reboot.rect)); await check('power action hover shows its contextual tooltip', value => value.power.rebootTooltip); await screenshot('tasks-power-tooltip'); click(state.power.reboot.rect); state = await check('power destructive action arms confirmation', value => value.power.armedAction === 'reboot' && value.power.arms === 1 && confirmationFits(value)); await screenshot('tasks-power-armed'); click(state.chips.audio.rect); state = await check('switching panels cancels power confirmation', value => value.popup.name === 'audio' && !value.power.armed && value.power.cancels === 1 && value.power.confirms === 0); assert.equal(state.audio.scrollY, retainedAudioScroll, 'audio scroll survives switching away and back'); state = await open('power', value => value.power.poweroff?.rect); assert.equal(state.power.armed, false, 'power must reopen unarmed after switching'); click(state.power.poweroff.rect); state = await check('power can arm again after switch cancellation', value => value.power.armedAction === 'poweroff' && value.power.arms === 2 && confirmationFits(value)); click(outsideRect(state)); state = await check('outside dismissal cancels power confirmation', value => !value.popup.open && value.popup.lastCloseReason === 'outside' && !value.power.armed && value.power.cancels === 2); state = await open('power', value => value.power.reboot?.rect); assert.equal(state.power.armed, false, 'power must reopen unarmed after outside dismissal'); const immediateBefore = state.power.immediate; for (const [index, name] of ['lock', 'suspend', 'monitor-off'].entries()) { click(state.power.actions.find(action => action.name === name).control.rect); key('space'); state = await check(`${name} accepts pointer and Space exactly once each`, value => value.power.immediate === immediateBefore + (index + 1) * 2 && !value.power.armed); } key('Tab'); state = await check('Tab reaches logout in the compact action grid', value => value.power.actions.find(action => action.name === 'logout').control.focused); key('Return'); state = await check('Enter arms logout without executing it', value => value.power.armedAction === 'logout' && value.power.confirms === 0 && value.popup.card.height === 348 && confirmationFits(value)); assert(within(state.power.confirmation, state.width, state.height)); await screenshot('tasks-power-logout'); click(state.power.cancel.rect); state = await check('cancel shrinks power and returns focus to the armed action', value => !value.power.armed && value.popup.card.height === 232 && value.power.actions.find(action => action.name === 'logout').control.focused && value.power.confirms === 0); key('Tab'); key('Return'); state = await check('keyboard activation arms reboot', value => value.power.armedAction === 'reboot' && confirmationFits(value)); key('Tab'); key('Tab'); await check('Tab reaches cancel before confirmation', value => value.power.cancel.focused); key('Tab'); await check('Tab reaches explicit destructive confirmation', value => value.power.confirm.focused); key('shift+Tab'); await check('reverse Tab returns to cancel', value => value.power.cancel.focused); key('Tab'); key('space'); state = await check('Space confirms only the armed action once and restores focus', value => value.power.confirms === 1 && !value.power.armed && value.power.reboot.focused && value.popup.card.height === 232); ipc('external', 'power-desktop'); state = await check('desktop power has no blank battery region', value => !value.power.battery && value.popup.card.height === 172); await screenshot('tasks-power-desktop'); ipc('external', 'power-devices'); state = await check('external batteries fit two rows even without a main battery', value => value.power.devices?.height === 72 && value.popup.card.height === 256); await screenshot('tasks-power-devices'); ipc('external', 'power-no-devices'); ipc('external', 'power-laptop'); await check('laptop power returns to its compact resting size', value => value.power.battery && !value.power.devices && value.popup.card.height === 232); state = await open('connectivity', value => value.connectivity.row?.rect); click(state.connectivity.row.rect); state = await check('secured network expands a focused masked editor', value => value.connectivity.expanded && value.connectivity.input?.rect && value.connectivity.input.focused && value.connectivity.inputMasked); input('type', 'fixture-only'); state = await check('network secret remains represented only as masked presence', value => value.connectivity.hasInput && value.connectivity.inputMasked && value.connectivity.sameInput); ipc('external', 'network-update'); state = await check('live network update preserves masked input and row identity', value => value.connectivity.generation === 1 && value.connectivity.expanded && value.connectivity.hasInput && value.connectivity.inputMasked && value.connectivity.sameInput && value.connectivity.connects === 0); await screenshot('tasks-connectivity-masked-update'); state = await open('notifications', value => value.notifications.input?.rect); click(state.notifications.input.rect); state = await check('pointer focuses the notification reply editor', value => value.notifications.input.focused); input('type', 'fixture-only'); state = await check('notification reply draft is tracked as a boolean only', value => value.notifications.hasInput && value.notifications.knownDraft && value.notifications.edits > 0); ipc('external', 'notification-update'); state = await check('external notification update preserves the live reply editor', value => value.notifications.externalUpdates === 1 && value.notifications.hasInput && value.notifications.knownDraft && value.notifications.sameInput); click(state.chips.notifications.rect); await check('closing notifications retains the unsent reply draft', value => !value.popup.open && value.notifications.knownDraft); state = await open('notifications', value => value.notifications.input?.rect); assert(state.notifications.hasInput && state.notifications.knownDraft, 'notification draft must survive reopen'); click(state.notifications.input.rect); await check('reopened notification draft accepts keyboard focus', value => value.notifications.input.focused); key('Return'); state = await check('failed reply keeps the draft and exposes retryable failure', value => value.notifications.sends === 1 && value.notifications.failed && value.notifications.hasInput && value.notifications.knownDraft); await screenshot('tasks-notification-reply-retry'); key('Escape'); await check('Escape dismisses an active reply without discarding it', value => !value.popup.open && value.notifications.knownDraft); state = await open('brightness', value => value.brightness.slider?.rect); click({ ...state.brightness.slider.rect, width: state.brightness.slider.rect.width * 1.5 }); state = await check('brightness pointer updates the display', value => value.brightness.writes === 1 && value.brightness.level > 0.7); key('Right'); state = await check('brightness keyboard updates the same display', value => value.brightness.writes === 2); ipc('external', 'brightness-update'); state = await check('brightness follows external updates after local actions without writeback', value => value.brightness.value === 0.25 && value.brightness.writes === 2); await screenshot('tasks-brightness-live'); ipc('external', 'brightness-unavailable'); state = await check('unavailable display disables its slider', value => !value.brightness.slider.enabled); click(state.brightness.slider.rect); assert.equal(snapshot().brightness.writes, 2); await screenshot('tasks-brightness-unavailable'); for (const docked of [false, true]) { const outputs = [{ name: 'LVDS-1', model: 'internal panel' }]; if (docked) outputs.push({ name: 'HDMI-A-1', model: 'external monitor' }); const displays = Brightness.displayRows({ present: true, level: 0.5 }, outputs, {}); ipc('external', 'brightness-state:' + JSON.stringify({ displays, global: Brightness.globalState(displays), setup: { needed: displays.some(row => row.kind === 'ddc' && !row.controllable), available: true }, })); await check(docked ? 'docked laptop offers DDC only for its external monitor' : 'internal laptop panel has backlight control without DDC setup', value => value.brightness.builtIn?.enabled && value.brightness.setupVisible === docked && value.brightness.displayIds.join(',') === (docked ? 'backlight,ddc:HDMI-A-1' : 'backlight')); await screenshot(docked ? 'tasks-brightness-docked' : 'tasks-brightness-laptop'); } ipc('external', 'brightness-state:null'); state = await open('noko', value => value.noko.capture?.rect); click(state.noko.capture.rect); state = await check('Noko capture button starts the fixture action', value => value.noko.listening && value.noko.triggers === 1); key('space'); state = await check('Noko capture supports keyboard stop', value => !value.noko.listening && value.noko.triggers === 2); key('Tab'); await check('Noko history copy is keyboard reachable', value => value.noko.copy?.focused); key('space'); await check('Noko copy displays completion', value => value.noko.copied); await check('Noko copy reaches the native sandbox clipboard', () => run('wl-paste', ['--list-types']).split('\n').includes('text/plain') && run('wl-paste', ['--no-newline', '--type', 'text/plain']) === 'synthetic capture 0'); for (let i = 0; i < 8; i++) { key('Tab'); await check(`Noko history keyboard focus remains visible ${i}`, value => { const f = value.noko.focusedRect, v = value.noko.viewport; return f && v && f.y >= v.y && f.y + f.height <= v.y + v.height; }); } const nokoScroll = snapshot().noko.scrollY; assert(nokoScroll > 0, 'Noko history actually crosses its viewport boundary'); await screenshot('tasks-noko-history'); state = await open('vault', value => value.vault.search?.rect); await check('vault search receives focus', value => value.vault.search.focused); for (let i = 0; i < 14; i++) { key('Down'); state = await check(`vault selected result remains visible ${i}`, value => { const r = value.vault.selected, v = value.vault.viewport; return value.vault.selectedId === `item-${i + 1}` && r && v && r.y >= v.y && r.y + r.height <= v.y + v.height; }); } assert(state.vault.scrollY > 0, 'vault selection actually crosses viewport boundary'); await screenshot('tasks-vault-keyboard'); input('type', 'absent'); await check('zero vault matches clear stale selection and detail', value => value.vault.count === 0 && value.vault.detailEmpty && value.vault.selectedId === '' && value.vault.queryKnown); await screenshot('tasks-vault-empty'); key('ctrl+a'); key('BackSpace'); await check('clearing vault search restores a valid selection', value => value.vault.count === 20 && value.vault.selectedId === 'item-0'); for (let i = 0; i < 12; i++) key('Down'); state = await check('vault navigation stores its scroll position', value => value.vault.selectedId === 'item-12' && value.vault.scrollY > 0); const vaultScroll = state.vault.scrollY; click(state.chips.vault.rect); await check('vault closes', value => !value.popup.open); state = await open('vault', value => value.vault.search?.rect); assert.equal(state.vault.selectedId, 'item-12'); assert.equal(state.vault.scrollY, vaultScroll, 'vault restores scroll after Loader recreation'); key('Escape'); await check('Escape closes vault search', value => !value.popup.open); ipc('external', 'vertical'); await check('real Bar moves to vertical layout', value => value.orientation === 'vertical' && Object.values(value.chips).every(chip => within(chip.rect, value.width, value.height))); await check('vertical clock retains its centre and widget-clearance rule', value => value.clock && value.clock.x + value.clock.width / 2 === value.barWidth / 2 && value.clock.y === Math.min(value.height / 2 - value.clock.height / 2, Math.min(...Object.values(value.chips).map(chip => chip.rect.y)) - value.clock.height - 12)); await check('vertical Bar accent remains a right-edge hairline after orientation change', value => value.barEdge.width === 1 && value.barEdge.height === value.height && value.barEdge.x === value.barWidth - 1); for (const [name, primary] of [ ['clipboard', value => value.clipboard.search?.rect], ['launcher', value => value.launcher.search?.rect], ['audio', value => value.audio.slider?.rect], ['media', value => value.media.play?.rect], ['tray', value => value.tray.primary?.rect], ['power', value => value.power.reboot?.rect], ['connectivity', value => value.connectivity.row?.rect], ['notifications', value => value.notifications.input?.rect], ['brightness', value => value.brightness.slider?.rect], ['vault', value => value.vault.search?.rect], ['noko', value => value.noko.capture?.rect], ]) { state = await open(name, primary); if (name === 'clipboard') assert(state.clipboard.queryKnown && state.clipboard.visibleRows === 2); if (name === 'launcher') assert(state.launcher.queryKnown && state.launcher.visibleRows === 1); if (name === 'connectivity') assert(state.connectivity.expanded && !state.connectivity.hasInput, 'SSID survives but credential input does not'); if (name === 'notifications') assert(state.notifications.knownDraft && state.notifications.hasInput); if (name === 'noko') assert.equal(state.noko.scrollY, nokoScroll, 'Noko history restores actual viewport position after orientation change'); if (name === 'vault') { const r = state.vault.selected, v = state.vault.viewport; assert.equal(state.vault.scrollY, vaultScroll); assert(r && v && r.y >= v.y && r.y + r.height <= v.y + v.height, 'vault selection is visibly restored after orientation change'); } await screenshot(`tasks-${name}-vertical`); if (name === 'media' || name === 'tray') { ipc('external', `${name}-empty`); state = await check(`${name} empty popup stays compact in vertical layout`, value => value[name].emptyState && value.popup.card.width === 352 && value.popup.card.height === 88); assertGeometry(state, state[name].emptyState, `vertical empty ${name}`); await screenshot(`tasks-${name}-empty-vertical`); ipc('external', `${name}-populated`); await check(`${name} repopulates in vertical layout`, value => !value[name].emptyState && primary(value)); } } }