import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { stripVTControlCharacters } from 'node:util'; import { setTimeout as delay } from 'node:timers/promises'; import { prepareNiriIntegration, runNiriIntegration } from './test-fixtures/ui/niri-integration.mjs'; import { taskSelection, taskOverlayFixtures, taskEntryFixture, runTasks } from './test-fixtures/ui/tasks.mjs'; import { runResourceProbe } from './test-fixtures/ui/resource-probe.mjs'; const project = dirname(fileURLToPath(import.meta.url)); export function childEnvironment(runtime, display, socket) { const directory = realpathSync(runtime); for (const path of [display, socket]) { assert.equal(dirname(realpathSync(path)), directory, 'refusing non-child socket'); assert(lstatSync(path).isSocket(), 'child endpoint must be a socket'); } assert.match(basename(display), /^wayland-\d+$/); assert.match(basename(socket), /^niri\..*\.sock$/); return { ...process.env, WAYLAND_DISPLAY: display, NIRI_SOCKET: socket, XDG_RUNTIME_DIR: directory }; } export function assertCleanLog(text) { assert(!/\b(?:WARN(?:ING)?|ERROR)\b|Error parsing|binding loop|ReferenceError|TypeError|deprecated|Could not|failed/i.test(text), text); } // Owner-approved sandbox exceptions only. Preserve raw logs and fail every // other diagnostic, including these same messages at another level or source. export function assertNiriLog(text, writeFailure = null) { const fallback = 'failed building default dmabuf feedback, falling back to v3: error getting EGL device render node\n\nCaused by:\n None of the following EGL extensions is supported by the underlying EGL implementation, at least one is required: ["EGL_EXT_device_drm"]'; const saving = 'saving screenshot to "/evidence/missing-directory/native-failure.png"'; const failedWrite = 'error saving screenshot image: Os { code: 2, kind: NotFound, message: "No such file or directory" }'; if (writeFailure) { assert(Number.isSafeInteger(writeFailure.start) && Number.isSafeInteger(writeFailure.end)); assert(writeFailure.start >= 0 && writeFailure.start < writeFailure.end && writeFailure.end <= text.length, 'invalid expected diagnostic interval'); for (const offset of [writeFailure.start, writeFailure.end]) assert(offset === 0 || text[offset - 1] === '\n', 'diagnostic interval must end on record boundaries'); } const parts = writeFailure ? [text.slice(0, writeFailure.start), text.slice(writeFailure.start, writeFailure.end), text.slice(writeFailure.end)] : [text]; const result = { softwareFallbacks: 0, expectedWriteFailures: 0 }; for (const [index, part] of parts.entries()) { let previous = null; const records = stripVTControlCharacters(part).split(/(?=^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z\s+(?:TRACE|DEBUG|INFO|WARN|ERROR)\s)/m); for (const record of records) { const match = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+(\S+): ([\s\S]*?)\s*$/.exec(record); const [, level, source, message] = match || []; if (level === 'DEBUG' && source === 'niri::backend::winit' && message === fallback) { assert(++result.softwareFallbacks <= 1, 'duplicate software fallback'); } else if (writeFailure && index === 1 && level === 'WARN' && source === 'niri::niri' && message === failedWrite) { assert.deepEqual(previous, ['DEBUG', 'niri::niri', saving], 'write warning must follow the deliberate destination'); assert(++result.expectedWriteFailures === 1, 'duplicate expected write warning'); } else assertCleanLog(record); previous = match ? [level, source, message] : null; } } assert.equal(result.expectedWriteFailures, writeFailure ? 1 : 0, 'missing deliberate write warning'); return result; } export function assertPureService(text) { assert.match(text, /^import QtQuick\s+Item\s*\{/); assert.equal((text.match(/\bimport\b/g) || []).length, 1, 'mock imports must be QtQuick only'); assert.deepEqual(text.match(/\b[A-Z]\w*\s*\{/g), ['Item {'], 'mock must contain only Item state/functions'); assert(!/\b(?:Qt|Quickshell|Process|FileView|XMLHttpRequest|WebSocket|WorkerScript|PolkitAgent|PamContext|fetch|eval|Function)\b/.test(text), 'privileged or dynamic mock API'); } export async function stopChild(child) { if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; const closed = new Promise(resolve => child.once('close', resolve)); child.kill('SIGTERM'); const force = setTimeout(() => child.kill('SIGKILL'), 2000); try { await closed; } finally { clearTimeout(force); } } export function replayRequest(args) { const fixture = args[0] ?? 'all'; const size = args[1]; assert(args.length <= 2 && ['all', 'controls', 'popouts', 'system', 'privilege', 'noko', 'niri', taskSelection].includes(fixture) && (size === undefined || fixture === taskSelection && ['1366x768', '2560x1440'].includes(size)), 'usage: just ui-test [all|controls|popouts|system|privilege|noko|niri|tasks] [tasks: 1366x768|2560x1440]'); return { version: 1, fixture, command: `just ui-test ${fixture}${size ? ` ${size}` : ''}`, ...(size ? { size } : {}) }; } export function assertSafeCapture(snapshot) { assert(snapshot, 'snapshot unavailable for safe screenshot'); if (snapshot.auth?.visible) { assert(snapshot.auth.hasInput === false || snapshot.auth.inputMasked === true, 'refusing screenshot of unmasked input'); } } export async function captureUiFailure(error, { evidence, fixture, phase, snapshot, screenshot, write = writeFileSync }) { const record = { version: 1, fixture, phase, error: error.stack || String(error), snapshot, screenshot: null, captureErrors: [] }; try { record.screenshot = await screenshot('failure'); } catch (captureError) { record.captureErrors.push(captureError.stack || String(captureError)); } write(join(evidence, 'failure.json'), JSON.stringify(record, null, 2)); return record; } export async function finishUiRun(failure, { children, checkLogs, capture, stop = stopChild, report = error => console.error(error.stack || String(error)) }) { let captured = false; async function retainFailure() { if (!failure || captured) return; captured = true; try { await capture(failure); } catch (error) { report(error); } } // The first gate runs while niri is alive so infrastructure failures have pixels too. try { checkLogs(); } catch (error) { failure ||= error; } await retainFailure(); for (const child of [...children].reverse()) { try { await stop(child); } catch (error) { if (failure) report(error); else failure = error; } } // Retain the original complete-log gate, including diagnostics emitted on shutdown. try { checkLogs(); } catch (error) { failure ||= error; } await retainFailure(); if (failure) throw failure; } async function outer(replay) { mkdirSync(join(project, '.tmp'), { recursive: true }); const evidence = mkdtempSync(join(project, '.tmp/ui-test-')); const runtime = mkdtempSync(join(tmpdir(), 'nuguland-ui-runtime-')); writeFileSync(join(runtime, 'passwd'), `test:x:${process.getuid()}:${process.getgid()}:test:/home/test:/bin/sh\n`); const args = [ '--unshare-all', '--die-with-parent', '--new-session', '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/lib', '/lib', '--symlink', 'usr/lib', '/lib64', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--ro-bind', '/etc/fonts', '/etc/fonts', '--ro-bind', '/etc/ld.so.cache', '/etc/ld.so.cache', '--ro-bind', '/etc/machine-id', '/etc/machine-id', '--ro-bind', join(runtime, 'passwd'), '/etc/passwd', '--dir', '/home/test', '--dir', '/run/user', '--overlay-src', project, '--tmp-overlay', '/project', '--bind', evidence, '/evidence', '--bind', runtime, '/run/user/1000', '--chdir', '/project', '--clearenv', '--setenv', 'PATH', '/usr/bin', '--setenv', 'HOME', '/home/test', '--setenv', 'XDG_RUNTIME_DIR', '/run/user/1000', '--setenv', 'XDG_CONFIG_HOME', '/home/test/.config', '--setenv', 'XDG_CACHE_HOME', '/home/test/.cache', '--setenv', 'XDG_DATA_HOME', '/home/test/.local/share', '--setenv', 'XDG_STATE_HOME', '/home/test/.local/state', '--setenv', 'QT_QPA_PLATFORM', 'wayland', '--setenv', 'LIBGL_ALWAYS_SOFTWARE', '1', '--setenv', 'NUGULAND_UI_SANDBOX', '1', 'dbus-run-session', '--', 'node', '/project/ui-test.mjs', '--inside', replay.fixture, ...(replay.size ? [replay.size] : []) ]; console.log(`evidence: ${evidence}`); writeFileSync(join(evidence, 'replay.json'), JSON.stringify({ ...replay, phase: 'startup' }, null, 2)); writeFileSync(join(evidence, 'command.json'), JSON.stringify({ cwd: project, command: ['bwrap', ...args] }, null, 2)); const child = spawn('bwrap', args, { stdio: ['ignore', 'pipe', 'pipe'] }); for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { appendFileSync(join(evidence, 'runner.log'), data); process.stdout.write(data); }); let interrupted = false; const interrupt = () => { if (interrupted) return; interrupted = true; void stopChild(child); }; process.on('SIGINT', interrupt); process.on('SIGTERM', interrupt); const timeout = setTimeout(interrupt, 300_000); try { const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (code, signal) => resolve(signal ? -1 : code)); }); assert(!interrupted, `nested UI test interrupted; full logs: ${evidence}`); assert.equal(code, 0, `nested UI test failed; full logs: ${evidence}`); } catch (error) { // An inner failure already owns its evidence; never replace it with bwrap's exit code. if (!existsSync(join(evidence, 'failure.json'))) { try { const { phase } = JSON.parse(readFileSync(join(evidence, 'replay.json'), 'utf8')); await captureUiFailure(error, { evidence, fixture: replay.fixture, phase, snapshot: null, screenshot: async () => { throw new Error('sandbox unavailable for capture'); } }); } catch (captureError) { console.error(captureError.stack); } } throw error; } finally { clearTimeout(timeout); process.off('SIGINT', interrupt); process.off('SIGTERM', interrupt); await stopChild(child); rmSync(runtime, { recursive: true }); assert(!existsSync(runtime), 'owned runtime was not removed'); } } async function inside(replay) { assert.equal(process.env.NUGULAND_UI_SANDBOX, '1', 'internal mode requires sandbox'); assert.equal(process.env.HOME, '/home/test'); assert.equal(process.env.WAYLAND_DISPLAY, undefined, 'parent Wayland connection must not be inherited'); assert.equal(process.env.DISPLAY, undefined, 'parent X11 connection must not be inherited'); assert(!existsSync('/run/dbus/system_bus_socket'), 'host system bus must be inaccessible'); assert(!existsSync('/home/oli'), 'host HOME must be inaccessible'); assert(process.env.DBUS_SESSION_BUS_ADDRESS, 'private session bus required'); const evidence = '/evidence'; const children = []; const runtimeLogs = []; let fixtureLog = ''; let phase = 'startup'; let latestSnapshot = null; let fixtureProcess = null; let expectedWriteFailure = null; let snapshot = () => { throw new Error('fixture snapshot not ready'); }; let failureScreenshot = async () => { throw new Error('child compositor not ready'); }; const controls = replay.fixture === 'all' || replay.fixture === 'controls'; const refactor = !['controls', 'niri', taskSelection].includes(replay.fixture); const tasks = replay.fixture === 'all' || replay.fixture === taskSelection; const integration = replay.fixture === 'all' || replay.fixture === 'niri'; const nativeNiri = readFileSync('/project/Niri.qml', 'utf8'); const nativeResources = readFileSync('/project/Resources.qml', 'utf8'); const taskOriginals = new Map(taskOverlayFixtures.map(({ target }) => [target, readFileSync(`/project/${target}`, 'utf8')])); const [outputWidth, outputHeight] = (replay.size || '1280x720').split('x').map(Number); function prepareTasks() { for (const { source, target } of taskOverlayFixtures) { const text = readFileSync(`/project/${source}`, 'utf8'); assertPureService(text); writeFileSync(`/project/${target}`, text); } writeFileSync(`/project/${taskEntryFixture.target}`, readFileSync(`/project/${taskEntryFixture.source}`, 'utf8').replace(taskEntryFixture.replace.from, taskEntryFixture.replace.to)); } function enterPhase(name) { phase = name; writeFileSync(join(evidence, 'replay.json'), JSON.stringify({ ...replay, phase }, null, 2)); console.log(`phase: ${name} (fixture: ${replay.fixture})`); } // Compositor system-bus watchers use the private bus, never the host bus. let env = { ...process.env, DBUS_SYSTEM_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS }; function run(command, args = []) { const result = spawnSync(command, args, { env, encoding: 'utf8', timeout: 30_000 }); appendFileSync(join(evidence, 'commands.log'), `${JSON.stringify([command, ...args])}\n${result.stdout || ''}${result.stderr || ''}exit: ${result.status}\n`); if (result.error) throw result.error; assert.equal(result.status, 0, `${command}: ${result.stdout}${result.stderr}`); return result.stdout.trim(); } function start(command, args, name, childEnv = env) { appendFileSync(join(evidence, 'commands.log'), `start: ${JSON.stringify([command, ...args])}\n`); const child = spawn(command, args, { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); const log = join(evidence, `${name}.log`); writeFileSync(log, ''); runtimeLogs.push(log); for (const stream of [child.stdout, child.stderr]) stream.on('data', data => appendFileSync(log, data)); child.on('error', error => { child.failure = error; }); children.push(child); return child; } async function waitFor(label, predicate) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { for (const child of children) { if (child.failure) throw child.failure; assert.equal(child.exitCode, null, `child exited during ${label}; inspect logs`); assert.equal(child.signalCode, null, `child signalled during ${label}; inspect logs`); } if (fixtureLog) assertCleanLog(readFileSync(fixtureLog, 'utf8')); const value = predicate(); if (value) return value; await delay(50); } throw new Error(`timeout: ${label}`); } let failure = null; try { enterPhase('startup'); // Drop a host-generated symlink only in the disposable overlay. rmSync('/project/.qmlls.ini', { force: true }); writeFileSync('/project/.qmlls.ini', ''); writeFileSync(join(evidence, 'versions.txt'), [run('weston', ['--version']), run('niri', ['--version']), run('qs', ['--version']), run('wdotool', ['--version']), run('/usr/lib/qt6/bin/qmllint', ['--version'])].join('\n') + '\n'); const runtime = process.env.XDG_RUNTIME_DIR; const busFlags = run('pkg-config', ['--cflags', '--libs', 'libsystemd']).split(/\s+/); run('cc', ['-std=c17', '-Wall', '-Wextra', '-Werror', '/project/test-fixtures/ui/session-bus.c', ...busFlags, '-o', '/tmp/nuguland-session-bus']); start('/tmp/nuguland-session-bus', [], 'session-bus'); await waitFor('private login/locale fixtures', () => readFileSync(join(evidence, 'session-bus.log'), 'utf8').includes('session fixtures ready')); const westonSocket = join(runtime, 'weston-headless'); const westonModules = []; if (integration) { const flags = run('pkg-config', ['--cflags', '--libs', 'libweston-15', 'wayland-server', 'xkbcommon']).split(/\s+/); run('cc', ['-std=gnu17', '-Wall', '-Wextra', '-Werror', '-shared', '-fPIC', '/project/test-fixtures/ui/niri-integration-keyboard.c', ...flags, '-o', '/tmp/niri-integration-keyboard.so']); run('mkfifo', [join(runtime, 'niri-integration-keyboard.fifo')]); westonModules.push('--modules=/tmp/niri-integration-keyboard.so'); } start('weston', ['--backend=headless', '--renderer=pixman', '--shell=kiosk', '--socket=weston-headless', `--width=${outputWidth}`, `--height=${outputHeight}`, '--idle-time=0', '--no-config', '--fake-seat', ...westonModules], 'weston'); await waitFor('headless Weston socket', () => existsSync(westonSocket) && lstatSync(westonSocket).isSocket()); env.WAYLAND_DISPLAY = westonSocket; run('niri', ['validate', '--config', '/project/test-fixtures/ui/niri.kdl']); const compositor = start('niri', ['--config', '/project/test-fixtures/ui/niri.kdl'], 'niri'); const endpoints = await waitFor('nested compositor sockets', () => { const files = readdirSync(runtime); const display = files.find(name => /^wayland-\d+$/.test(name)); const socket = files.find(name => /^niri\..*\.sock$/.test(name)); return display && socket ? [join(runtime, display), join(runtime, socket)] : null; }); env = { ...env, ...childEnvironment(runtime, ...endpoints), DBUS_SYSTEM_BUS_ADDRESS: env.DBUS_SYSTEM_BUS_ADDRESS }; await waitFor('Niri consumed private login/locale properties', () => { const log = readFileSync(join(evidence, 'session-bus.log'), 'utf8'); return ['org.freedesktop.login1.Manager.LidClosed', ...['X11Model', 'X11Layout', 'X11Variant', 'X11Options'].map(name => `org.freedesktop.locale1.${name}`)].every(name => log.includes(`read ${name}\n`)); }); assert.equal(run('busctl', ['--address', env.DBUS_SESSION_BUS_ADDRESS, 'get-property', 'org.freedesktop.login1', '/org/freedesktop/login1', 'org.freedesktop.login1.Manager', 'LidClosed']), 'b false'); assert.equal(run('busctl', ['--address', env.DBUS_SESSION_BUS_ADDRESS, 'get-property', 'org.freedesktop.locale1', '/org/freedesktop/locale1', 'org.freedesktop.locale1', 'X11Layout']), 's "us"'); assert.notEqual(env.WAYLAND_DISPLAY, westonSocket, 'input must target niri, not Weston'); async function screenshot(name) { // Never change the failing UI to make evidence look safe or successful. // Password-mode pixels are already masked; revealed input refuses capture. await delay(300); assert.equal(compositor.exitCode, null, 'child compositor exited before capture'); assert.equal(compositor.signalCode, null, 'child compositor signalled before capture'); childEnvironment(runtime, ...endpoints); if (fixtureProcess?.pid && fixtureProcess.exitCode === null && fixtureProcess.signalCode === null) assertSafeCapture(snapshot()); const path = join(evidence, `${name}.png`); run('niri', ['msg', 'action', 'screenshot-screen', '--show-pointer', 'false', '--path', path]); // Failure capture must not run the fixture-log gate that caused the failure. const deadline = Date.now() + 3000; while (Date.now() < deadline) { if (existsSync(path) && lstatSync(path).size > 0) return path; await delay(50); } throw new Error(`timeout: screenshot ${name}`); } failureScreenshot = screenshot; writeFileSync(join(evidence, 'outputs.json'), run('niri', ['msg', '--json', 'outputs'])); start('wdotool', ['--backend', 'wlr-protocols', 'prime'], 'input'); await waitFor('virtual input ready', () => readFileSync(join(evidence, 'input.log'), 'utf8').includes('ready')); if (refactor) { for (const name of ['CalDav', 'Resources', 'Privilege', 'Noko', 'Niri', 'Audio', 'Network', 'Vault']) { const source = `/project/test-fixtures/ui/services/${name}.qml`; assertPureService(readFileSync(source, 'utf8')); copyFileSync(source, `/project/${name}.qml`); } // Keep the scanner root at /project, as for the original shared fixture. writeFileSync('/project/refactor-ui-test.qml', readFileSync('/project/test-fixtures/ui/refactor.qml', 'utf8').replace('import "../.." as N', 'import "." as N')); } let integrationSetup; async function prepareIntegration() { integrationSetup = await prepareNiriIntegration({ run, start, waitFor, runtime, nativeNiri, nativeSocket: endpoints[1], assertPureService }); childEnvironment(runtime, endpoints[0], integrationSetup.socket); } if (replay.fixture === 'niri') await prepareIntegration(); if (replay.fixture === taskSelection) prepareTasks(); let fixture = controls ? '/project/ui-test.qml' : refactor ? '/project/refactor-ui-test.qml' : tasks ? '/project/tasks-ui-test.qml' : '/project/niri-integration-ui-test.qml'; const firstLog = controls ? 'quickshell' : refactor ? 'refactor' : tasks ? 'tasks' : 'niri-integration'; fixtureProcess = start('qs', ['--no-color', '-p', fixture], firstLog); fixtureLog = join(evidence, `${firstLog}.log`); await waitFor('quickshell loaded', () => { const log = readFileSync(fixtureLog, 'utf8'); assertCleanLog(log); return log.includes('Configuration Loaded'); }); const qmllsConfig = await waitFor('generated qmlls configuration', () => { const config = readFileSync('/project/.qmlls.ini', 'utf8'); return config.includes('importPaths=') && config.includes('buildDir=') ? config : null; }); writeFileSync(join(evidence, 'qmlls.ini'), qmllsConfig); console.log('pass: Quickshell generated its upstream-recommended qmlls configuration'); const ipc = (...args) => run('qs', ['ipc', '-p', fixture, 'call', 'uiTest', ...args]); snapshot = () => { const text = ipc('snapshot'); assert(!text.includes('fixture-only'), 'snapshot exposed synthetic input'); const state = JSON.parse(text); latestSnapshot = state; return state; }; const input = (...args) => run('wdotool', ['--backend', 'wlr-protocols', ...args]); const key = key => input('key', key); const click = rect => { input('mousemove', String(Math.round(rect.x + rect.width / 2)), String(Math.round(rect.y + rect.height / 2))); input('click', '1'); }; async function check(label, predicate) { const state = await waitFor(label, () => { const state = snapshot(); return predicate(state) ? state : null; }); appendFileSync(join(evidence, 'assertions.jsonl'), JSON.stringify({ label, state }) + '\n'); console.log(`pass: ${label}`); return state; } let state; if (controls) { enterPhase('controls'); state = await check('initial bindings and focus', s => !s.muted && !s.checked && s.volume === 0.3 && s.sliderValue === 0.3 && s.focus === 'mute'); assert.equal(state.width, 1280, 'headless output width'); assert.equal(state.height, 720, 'headless output height'); for (const name of ['surface', 'mute', 'slider', 'field']) { const rect = state[name]; assert(rect.x >= 0 && rect.y >= 0 && rect.width > 0 && rect.height > 0 && rect.x + rect.width <= state.width && rect.y + rect.height <= state.height, `${name} outside mapped surface`); } await screenshot('initial'); click(state.mute); state = await check('pointer toggles owner and checked state', s => s.muted && s.checked && s.writes === 1); key('space'); await check('space activates focused button', s => !s.muted && !s.checked && s.writes === 2); key('Return'); await check('enter activates focused button', s => s.muted && s.checked && s.writes === 3); key('Tab'); await check('tab reaches slider', s => s.focus === 'slider'); key('Right'); await check('arrow updates slider owner', s => Math.abs(s.volume - 0.35) < 0.001 && s.sliderValue === s.volume); click({ ...state.slider, width: state.slider.width * 1.5 }); await check('pointer updates slider owner', s => Math.abs(s.volume - 0.75) < 0.02 && s.sliderValue === s.volume); key('Left'); await check('left arrow decreases slider owner', s => Math.abs(s.volume - 0.7) < 0.02 && s.sliderValue === s.volume); key('Tab'); await check('tab reaches text input', s => s.focus === 'field'); input('type', 'headphones'); await check('text input updates fixture query', s => s.queryIsHeadphones && s.queryMatchesInput); key('Return'); await check('enter accepts text field', s => s.accepted === 1); key('Escape'); await check('escape cancels text field', s => s.cancelled === 1); key('shift+Tab'); await check('reverse traversal reaches slider', s => s.focus === 'slider'); key('shift+Tab'); state = await check('reverse traversal reaches button', s => s.focus === 'mute'); const writes = state.writes; ipc('external', 'true', '0.6', 'false'); state = await check('external state updates controls without writeback', s => s.muted && s.checked && s.volume === 0.6 && s.sliderValue === 0.6 && !s.enabled && s.writes === writes); click(state.mute); key('space'); click(state.slider); key('Right'); click(state.field); input('type', ' test'); await check('disabled controls reject input before the next field edit', s => s.queryIsEdited && s.queryMatchesInput && s.writes === writes); input('mousemove', String(state.width - 10), String(state.height - 10)); await check('pointer leaves button tooltip', s => !s.tooltipVisible); await screenshot('external-disabled'); for (const value of [0, 0.25, 0.75, 1]) { ipc('external', 'false', String(value), 'true'); state = await check(`external slider value ${value} is visible`, s => s.sliderValue === value); const before = state.writes; click(state.sliderThumb); state = await check(`pressing thumb at ${value} does not jump or write`, s => s.writes === before && s.volume === value && s.focus === 'slider'); } input('mousemove', String(state.sliderThumb.x + state.sliderThumb.width / 2), String(state.sliderThumb.y + state.sliderThumb.height / 2)); input('mousedown', '1'); await check('thumb drag holds pointer grab', s => s.sliderPressed); for (const value of [0.75, 0.25, 0]) { input('mousemove', String(Math.round(state.slider.x + 8 + (state.slider.width - 16) * value)), String(state.slider.y + state.slider.height / 2)); await check(`continuous thumb drag reaches ${value}`, s => s.sliderPressed && Math.abs(s.volume - value) < 0.01); } input('mouseup', '1'); await check('thumb release ends drag', s => !s.sliderPressed); click({ ...state.extras.card, width: 36 }); state = await check('interactive card primary activation is focused', s => s.extras.cardWrites === 1 && s.extras.cardFocused); key('Tab'); await check('Tab reaches nested card action without focusing its parent', s => s.extras.nestedFocused && !s.extras.cardFocused); key('space'); state = await check('nested action dispatches without parent activation', s => s.extras.nestedWrites === 1 && s.extras.cardWrites === 1); key('Tab'); state = await check('Tab reaches named icon action', s => s.extras.iconFocused && s.extras.iconName === 'refresh devices' && s.extras.sliderName === 'output volume'); input('mousemove', String(state.width - 10), String(state.height - 10)); await check('keyboard focus exposes themed icon tooltip', s => s.extras.iconTooltipVisible); await screenshot('keyboard-tooltip'); input('keydown', 'Return'); await delay(800); state = await check('held Enter does not repeatedly dispatch', s => s.extras.iconWrites === 0); input('keyup', 'Return'); await check('Enter release dispatches exactly once', s => s.extras.iconWrites === 1); input('keydown', 'space'); await delay(800); await check('held Space does not repeatedly dispatch', s => s.extras.iconWrites === 1); input('keyup', 'space'); await check('Space release dispatches exactly once', s => s.extras.iconWrites === 2); click(state.extras.disabled); await check('unavailable control never dispatches', s => s.extras.disabledWrites === 0); click({ ...state.extras.field, width: 4 }); await check('field padding focuses editor with its empty hint', s => s.extras.fieldFocused && s.extras.fieldHintVisible); input('type', 'padding'); await check('typing after padding click edits normally', s => s.extras.fieldEdited && !s.extras.fieldHintVisible); input('mousemove', ...[state.extras.longButton.x + 8, state.extras.longButton.y + 8].map(String)); await check('long useful tooltip opens on pointer hover', s => s.extras.longTooltipVisible); await screenshot('wrapped-tooltip'); input('mousemove', String(state.width - 10), String(state.height - 10)); await check('leaving tooltip trigger dismisses it', s => !s.extras.longTooltipVisible); await screenshot('controls-repaired'); assertCleanLog(readFileSync(join(evidence, 'quickshell.log'), 'utf8')); console.log('shared-control UI assertions passed; screenshots require visual review'); } if (controls && refactor) { // Only the completed fixture stops. Weston, niri, input and the private bus stay alive. await stopChild(fixtureProcess); assertCleanLog(readFileSync(fixtureLog, 'utf8')); children.splice(children.indexOf(fixtureProcess), 1); latestSnapshot = null; fixture = '/project/refactor-ui-test.qml'; fixtureProcess = start('qs', ['--no-color', '-p', fixture], 'refactor'); fixtureLog = join(evidence, 'refactor.log'); await waitFor('refactor fixture loaded', () => readFileSync(fixtureLog, 'utf8').includes('Configuration Loaded')); } const hasText = (rows, text) => rows.some(row => row.text === text); const within = (rect, width, height) => rect && rect.width > 0 && rect.height > 0 && rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= width && rect.y + rect.height <= height; function geometry(s, name) { const vertical = s.orientation === 'vertical'; const width = name === 'clock' ? 760 : 780; const height = name === 'clock' ? 580 : 600; assert.deepEqual(s.popup.contentSize, { width, height }); assert.deepEqual(s.card, { x: vertical ? 56 : Math.round((1280 - width) / 2) + 8, y: vertical ? Math.round((720 - height) / 2) + 8 : 56, width: width - 16, height: height - 16 }); assert(within(s.card, s.width, s.height)); assert(within(s.panel, s.width, s.height)); assert.equal(s.catalog.entries[name].component, name === 'clock' ? 'calendar-agenda' : 'system-resources'); } function diagnostics() { const text = run('qs', ['ipc', '-p', fixture, 'call', 'nuguland', 'diagnostics']); assert(!text.includes('diagnostic-private-sentinel'), 'diagnostics exposed private fixture state'); const data = JSON.parse(text); assert.equal(data.version, 1); assert.deepEqual(data.services.niri, { available: true, ready: true, windowCount: 1, workspaceCount: 1 }); assert.deepEqual(data.services.vault, { status: 'locked', lockPending: false }); assert.deepEqual(data.displays, [{ x: 0, y: 0, width: 1280, height: 720, scale: 1 }]); appendFileSync(join(evidence, 'diagnostics.jsonl'), text + '\n'); return data; } async function opened(name) { const s = await check(`${name} open in mapped ${snapshot().orientation} geometry`, s => s.popup.name === name && s.popup.phase === 'open' && s.card && s.card.y === s.popup.popupCardFrame.currentRect.y); geometry(s, name); const diagnostic = diagnostics(); assert.equal(diagnostic.popup.name, name); assert.equal(diagnostic.popup.phase, 'open'); assert.deepEqual(diagnostic.popup.card, s.card); return s; } if (refactor) state = await check('refactor initially closed without resource polling', s => !s.popup.open && !s.resources.open); if (replay.fixture === 'all' || replay.fixture === 'popouts') { enterPhase('popouts'); for (const orientation of ['horizontal', 'vertical']) { ipc('external', orientation); state = snapshot(); click(state.buttons.clock); state = await opened('clock'); if (orientation === 'horizontal') { await screenshot('clock-setup'); click(state.calendar.url); input('type', 'https://fixture.invalid/dav'); click(state.calendar.user); input('type', 'fixture-user'); key('Tab'); input('type', 'fixture-only'); state = await check('calendar draft edits stay in the service until save', s => s.calendar.draftsEdited && s.calendar.saves === 0); click(state.calendar.save); await check('calendar save receives literal drafts and renders empty agenda', s => s.calendar.saves === 1 && s.calendar.savedDrafts && hasText(s.text, 'keine termine')); await screenshot('clock-empty'); ipc('external', 'events'); await check('external calendar events render while open without another save', s => s.calendar.todayItems === 1 && hasText(s.text, 'fixture appointment') && s.calendar.saves === 1); } await screenshot(`clock-${orientation}`); state = snapshot(); let opens = state.resources.opens; click(state.buttons.system); state = await opened('system'); assert.equal(state.resources.open, true); assert.equal(state.resources.opens, opens + 1); if (orientation === 'horizontal') { assert(hasText(state.text, 'fixture-host')); assert(hasText(state.text, 'fixture service failed')); assert(hasText(state.text, 'fixture-worker · 25%')); await check('process line is visible above current issues', s => { const process = s.text.find(row => row.text === 'fixture-worker · 25%')?.rect; const issues = s.text.find(row => row.text === 'needs attention')?.rect; return within(process, s.width, s.height) && issues && process.y + process.height <= issues.y; }); await screenshot('system-populated'); click(state.resources.action); state = await check('resource issue action routes once with the issue id', s => s.resources.actions === 1 && s.resources.lastAction === 'fixture-unit'); key('Tab'); await check('Tab reaches the agent action after open', s => s.resources.agentFocused); key('shift+Tab'); key('Tab'); key('Return'); state = await check('Enter dispatches one agent request and disables duplicates', s => s.resources.agentPending && s.resources.agentActions === 1 && s.resources.agentIssueId === 'fixture-unit'); click(state.resources.agent); await check('pending pointer does not dispatch twice', s => s.resources.agentActions === 1); ipc('external', 'agent-failure'); state = await check('missing executable leaves visible failure and retry', s => !s.resources.agentPending && hasText(s.text, 'pi or wezterm unavailable in session path')); await screenshot('system-agent-unavailable'); click(state.resources.agent); await check('pointer retries the agent request once', s => s.resources.agentActions === 2 && s.resources.agentPending); ipc('external', 'agent-success'); state = await check('agent dispatch closes overlay and releases polling', s => !s.popup.open && !s.resources.open && s.popup.lastCloseReason === 'agent-dispatched'); click(state.buttons.system); state = await opened('system'); opens++; ipc('external', 'resource-incidents'); state = await check('battery and updates omit agent actions while crash retains one', s => !!s.resources.crashAgent && !s.resources.batteryAgent && !s.resources.updatesAgent && !!s.resources.updatesOpen); await check('crash and oom history is neutral while current failures retain severity', s => { const history = s.resources.issueRows.filter(row => row.historical); const service = s.resources.issueRows.find(row => row.id === 'fixture-unit'); const battery = s.resources.issueRows.find(row => row.id === 'battery-health'); return hasText(s.text, 'incident history') && history.length === 2 && history.every(row => row.clock && !row.attention && !row.destructive && !row.highlighted) && service?.destructive && battery?.attention; }); // The process line moves later rows below the viewport. // Reach them through real keyboard traversal and focus reveal. for (let attempts = 0; attempts < 10 && !snapshot().resources.updatesFocused; attempts++) key('Tab'); state = await check('keyboard reveals the update action below the process line', s => s.resources.updatesFocused && s.resources.updatesOpen.y + s.resources.updatesOpen.height <= s.panel.y + s.panel.height); click(state.resources.updatesOpen); await check('updates retain their existing open action', s => s.resources.actions === 2 && s.resources.lastAction === 'updates'); await screenshot('system-agent-eligibility'); // Tab skips non-actionable battery wear; then coverage and event open. key('Tab'); key('Tab'); key('Tab'); key('space'); state = await check('Space targets one crash group with all three occurrences visible', s => s.resources.agentPending && s.resources.agentIssueId === s.resources.crashGroupId && hasText(s.text, '3 crashes · latest sigsegv · pid 123') && hasText(s.text, '4 recorded')); const crashGroupId = state.resources.crashGroupId; ipc('external', 'agent-failure'); await screenshot('system-grouped-crashes'); key('shift+Tab'); key('Tab'); key('Return'); state = await check('Enter retries the eligible historical agent action', s => s.resources.agentPending && s.resources.agentIssueId === crashGroupId); ipc('external', 'resource-more-crashes'); state = await check('new occurrence updates count and latest time without changing group or pending state', s => s.resources.agentPending && s.resources.agentIssueId === crashGroupId && s.resources.crashGroupId === crashGroupId && hasText(s.text, '4 crashes · latest sigsegv · pid 200') && hasText(s.text, '5 recorded') && s.text.some(row => row.text.startsWith('latest · just now'))); const groupRequests = state.resources.agentActions; click(state.resources.crashAgent); await check('updated pending group still suppresses duplicate pointer dispatch', s => s.resources.agentActions === groupRequests); ipc('external', 'agent-failure'); click(state.resources.crashOpen); await check('group open routes the stable id to its latest event', s => s.resources.actions === 3 && s.resources.lastAction === crashGroupId); click(state.resources.crashAgent); await check('pointer retries the updated crash group', s => s.resources.agentActions === groupRequests + 1 && s.resources.agentIssueId === crashGroupId); ipc('external', 'agent-failure'); await screenshot('system-incidents'); key('Tab'); key('Tab'); await screenshot('system-long-incident'); ipc('external', 'resource-history-unavailable'); await check('incomplete history retains recorded incidents', s => !!s.resources.crashAgent && hasText(s.text, 'incident history unavailable or incomplete')); ipc('external', 'empty-resources'); await check('external empty issues and processes render all-clear', s => hasText(s.text, 'disk · memory · battery · updates ok') && !hasText(s.text, 'fixture service failed') && !hasText(s.text, 'fixture-worker · 25%') && !s.resources.action && s.resources.actions === 3); } await screenshot(`system-empty-${orientation}`); click(state.buttons.clock); state = await opened('clock'); assert.equal(state.resources.open, false, 'switching away must release resource gate'); assert.equal(state.resources.opens, state.resources.closes); click(state.buttons.system); state = await opened('system'); assert.equal(state.resources.opens, opens + 2); key('Escape'); state = await check('Escape closes popup and releases resource lifecycle gate', s => !s.popup.open && !s.resources.open && s.popup.lastCloseReason === 'escape-key'); assert.equal(state.resources.opens, state.resources.closes); click(state.buttons.system); state = await opened('system'); assert.equal(state.resources.opens, opens + 3); click({ x: 1200, y: 680, width: 10, height: 10 }); state = await check('outside pointer closes reopened system and releases gate', s => !s.popup.open && !s.resources.open && s.popup.lastCloseReason === 'outside'); assert.equal(state.resources.opens, state.resources.closes); const refreshes = state.calendar.refreshes; click(state.buttons.clock); state = await opened('clock'); assert.equal(state.calendar.refreshes, refreshes + 1); assert(hasText(state.text, 'fixture appointment')); click(state.buttons.clock); await check('bar pointer closes reopened clock', s => !s.popup.open && s.popup.lastCloseReason === 'bar-toggle'); } } if (replay.fixture === 'all' || replay.fixture === 'privilege') { enterPhase('privilege'); ipc('external', 'privilege'); state = await check('privilege overlay maps and focuses literal input', s => s.auth.visible && s.auth.active && s.auth.inputFocused); assert(within(state.auth.card, state.width, state.height)); assert(state.auth.shake.height > 0); assert.equal(state.auth.shake.height, state.auth.shakeContentHeight); assert.equal(state.auth.shake.width, state.auth.card.width - 44); await screenshot('privilege-initial'); key('Tab'); await check('Tab reaches password reveal without focus theft', s => s.auth.revealFocused && !s.auth.inputFocused); key('Tab'); await check('Tab reaches authentication cancel', s => s.auth.cancelFocused); key('Tab'); await check('Tab reaches authentication authorize', s => s.auth.authorizeFocused); key('Tab'); await check('authentication traversal wraps to input', s => s.auth.inputFocused); key('shift+Tab'); await check('reverse Tab reaches authorize', s => s.auth.authorizeFocused); key('shift+Tab'); await check('reverse Tab reaches cancel', s => s.auth.cancelFocused); key('shift+Tab'); await check('reverse Tab reaches reveal', s => s.auth.revealFocused); key('shift+Tab'); await check('reverse Tab returns to input', s => s.auth.inputFocused); click(state.auth.input); input('type', 'fixture-only'); await check('privilege input is present and masked without serializing its text', s => s.auth.hasInput && s.auth.inputMasked); await screenshot('privilege-masked-input'); key('Return'); state = await check('privilege Enter routes literal submit and renders rejection', s => s.auth.submissions === 1 && s.auth.receivedLiteral && s.auth.active && hasText(s.auth.text, 'password rejected') && hasText(s.auth.text, 'fixture input')); const failureCard = state.auth.card; const failureHeight = state.auth.shake.height; for (let i = 0; i < 4; i++) { state = await check(`failure shake keeps supplied frame size ${i}`, s => s.auth.shake.height === s.auth.shakeContentHeight && s.auth.shake.height === failureHeight); assert.deepEqual(state.auth.card, failureCard); assert.equal(state.auth.shake.width, failureCard.width - 44); await delay(60); } await screenshot('privilege-rejected'); click(state.auth.reveal); state = await check('pointer reveal toggles without stealing input focus', s => s.auth.revealed && s.auth.revealFocused && !s.auth.inputFocused && hasText(s.auth.text, 'fixture input')); // Reveal is captured with an empty field, never even a synthetic secret. await screenshot('privilege-reveal-empty'); click(state.auth.reveal); await check('pointer hides input again', s => !s.auth.revealed); click(state.auth.input); input('type', 'fixture-only'); key('Return'); state = await check('second Enter renders fixture authorization success', s => s.auth.submissions === 2 && s.auth.receivedLiteral && s.auth.confirming && !s.auth.active && hasText(s.auth.text, 'authorized')); await screenshot('privilege-authorized'); ipc('external', 'privilege'); state = await check('new fixture request reacquires focus', s => s.auth.active && s.auth.inputFocused && !s.auth.confirming); click(state.auth.cancel); await check('pointer cancel dismisses privilege overlay', s => !s.auth.visible && s.auth.cancellations === 1); ipc('external', 'privilege'); state = await check('privilege reopens after pointer cancellation', s => s.auth.visible && s.auth.inputFocused); click(state.auth.input); input('type', 'fixture-only'); key('Escape'); await check('Escape cancels privilege without submitting', s => !s.auth.visible && s.auth.cancellations === 2 && s.auth.submissions === 2); } if (replay.fixture === 'all' || replay.fixture === 'noko') { enterPhase('noko'); ipc('external', 'horizontal'); for (const phase of ['capturing', 'transcribing', 'finished']) { ipc('external', phase); state = await check(`Noko renders ${phase}`, s => s.noko.visible && s.noko.phase === phase && hasText(s.noko.text, phase)); assert(within(state.noko.tab, state.width, state.height)); assert.equal(state.noko.tab.y, 66); assert.equal(state.noko.tab.x, Math.round((state.width - state.noko.tab.width) / 2)); if (phase !== 'finished') await check(`Noko ${phase} animation ticks`, s => s.noko.tick > state.noko.tick); await screenshot(`noko-${phase}`); } const finishedTick = state.noko.tick; await delay(180); assert.equal(snapshot().noko.tick, finishedTick); ipc('external', 'vertical'); state = await check('Noko geometry reacts to vertical bar', s => s.noko.tab.y === 18 && s.noko.tab.x === Math.round(48 + (s.width - 48 - s.noko.tab.width) / 2)); await screenshot('noko-vertical'); ipc('external', 'thicker-bar'); await check('Noko geometry reacts to bar thickness without remount', s => s.noko.tab.x === Math.round(64 + (s.width - 64 - s.noko.tab.width) / 2)); await screenshot('noko-thicker-bar'); ipc('external', 'idle'); await check('Noko idle removes overlay', s => !s.noko.visible && s.noko.phase === ''); } if (refactor) { const diagnostic = diagnostics(); const transitions = await waitFor('transition log reaches diagnostic operation', () => { const log = readFileSync(fixtureLog, 'utf8'); const events = log.split('\n').filter(line => line.includes('"kind":"popup-transition"')).map(line => JSON.parse(line.slice(line.indexOf('{')))); return diagnostic.popup.operationId === 0 || events.at(-1)?.operationId === diagnostic.popup.operationId ? events : null; }); assert(!JSON.stringify(transitions).includes('diagnostic-private-sentinel')); if (replay.fixture === 'all' || replay.fixture === 'popouts') { assert.deepEqual(transitions.slice(0, 4).map(({ operationId, event }) => [operationId, event]), [[1, 'opening'], [1, 'opened'], [2, 'switched'], [3, 'closed']]); assert.equal(transitions.at(-1).event, 'closed'); assert.equal(transitions.at(-1).operationId, diagnostic.popup.operationId); for (let i = 1; i < transitions.length; i++) { const event = transitions[i]; const previous = transitions[i - 1]; assert.equal(event.operationId, previous.operationId + (event.event === 'opened' ? 0 : 1)); if (event.event === 'opened') assert.equal(previous.event, 'opening'); } console.log('pass: redacted diagnostics correlate with runtime popup transitions'); } else assert.equal(transitions.length, 0); console.log('refactor UI assertions passed; screenshots require visual review'); } if (['all', 'popouts', 'system'].includes(replay.fixture)) { await stopChild(fixtureProcess); assertCleanLog(readFileSync(fixtureLog, 'utf8')); children.splice(children.indexOf(fixtureProcess), 1); latestSnapshot = null; enterPhase('system-boundaries'); await runResourceProbe({original: nativeResources, env, start, stop: stopChild, waitFor, ipc, screenshot, snapshot, setFixture(path, log, child) { fixture = path; fixtureLog = log; fixtureProcess = child; } }); } if (tasks) { if (refactor) { await stopChild(fixtureProcess); assertCleanLog(readFileSync(fixtureLog, 'utf8')); children.splice(children.indexOf(fixtureProcess), 1); fixtureLog = ''; latestSnapshot = null; prepareTasks(); fixture = '/project/tasks-ui-test.qml'; fixtureProcess = start('qs', ['--no-color', '-p', fixture], 'tasks'); fixtureLog = join(evidence, 'tasks.log'); await waitFor('task fixture loaded', () => readFileSync(fixtureLog, 'utf8').includes('Configuration Loaded')); } enterPhase(taskSelection); await runTasks({ ipc, snapshot, input, key, click, check, screenshot, run, delay }); } if (integration) { if (refactor) { await stopChild(fixtureProcess); assertCleanLog(readFileSync(fixtureLog, 'utf8')); children.splice(children.indexOf(fixtureProcess), 1); fixtureLog = ''; latestSnapshot = null; for (const [target, text] of taskOriginals) writeFileSync(`/project/${target}`, text); await prepareIntegration(); fixture = '/project/niri-integration-ui-test.qml'; fixtureProcess = start('qs', ['--no-color', '-p', fixture], 'niri-integration'); fixtureLog = join(evidence, 'niri-integration.log'); await waitFor('niri integration fixture loaded', () => readFileSync(fixtureLog, 'utf8').includes('Configuration Loaded')); } enterPhase('niri'); expectedWriteFailure = await runNiriIntegration({ run, start, check, snapshot, ipc, screenshot, click, input, key, waitFor, evidence, runtime, modeFile: integrationSetup.modeFile, compositorKey: async name => { childEnvironment(runtime, ...endpoints); assert.equal(compositor.exitCode, null); assert.equal(compositor.signalCode, null); const keys = { Escape: [1], Return: [28], 'ctrl+c': [29, 46] }[name]; assert(keys, 'unsupported nested compositor key'); for (const [key, pressed] of [...keys.map(key => [key, 1]), ...keys.toReversed().map(key => [key, 0])]) { const command = Buffer.alloc(12); [compositor.pid, key, pressed].forEach((value, index) => command.writeUInt32LE(value, index * 4)); writeFileSync(join(runtime, 'niri-integration-keyboard.fifo'), command); appendFileSync(join(evidence, 'input.log'), `nested compositor ${compositor.pid}: key ${key} state ${pressed}\n`); await delay(50); } }, stop: async child => { await stopChild(child); children.splice(children.indexOf(child), 1); } }); } enterPhase('runtime-diagnostics'); } catch (error) { failure = error; } finally { await finishUiRun(failure, { children, checkLogs: () => { let diagnostics; for (const log of runtimeLogs) { const text = readFileSync(log, 'utf8'); if (log === join(evidence, 'niri.log')) diagnostics = assertNiriLog(text, expectedWriteFailure); else assertCleanLog(text); } writeFileSync(join(evidence, 'runtime-diagnostics.json'), JSON.stringify({ ...diagnostics, expectedWriteFailure }, null, 2)); }, capture: error => captureUiFailure(error, { evidence, fixture: replay.fixture, phase, snapshot: latestSnapshot, screenshot: failureScreenshot }) }); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { if (process.argv[2] === '--inside') await inside(replayRequest(process.argv.slice(3))); else await outer(replayRequest(process.argv.slice(2))); } catch (error) { console.error(error.stack); process.exitCode = 1; } }