import assert from 'node:assert/strict'; import test from 'node:test'; import { buildNotifySendEnv, createNotifySendReplacer, resolveDefaultNotificationIconPath, } from './notification'; test('notify-send child environment drops the AppImage library-path override', () => { const env = buildNotifySendEnv({ LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib', DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus', HOME: '/home/user', }); assert.equal(env.LD_LIBRARY_PATH, undefined); assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus'); assert.equal(env.HOME, '/home/user'); }); test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => { const path = resolveDefaultNotificationIconPath({ platform: 'linux', resourcesPath: '/opt/SubMiner/resources', appPath: '/opt/SubMiner/resources/app.asar', dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils', cwd: '/opt/SubMiner', joinPath: (...parts) => parts.join('/'), fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png', }); assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner.png'); }); test('default notification icon prefers the square app icon when bundled images are available', () => { const path = resolveDefaultNotificationIconPath({ platform: 'linux', resourcesPath: '/opt/SubMiner/resources', appPath: '/opt/SubMiner/resources/app.asar', dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils', cwd: '/opt/SubMiner', joinPath: (...parts) => parts.join('/'), fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png' || candidate === '/opt/SubMiner/resources/assets/SubMiner-square.png', }); assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner-square.png'); }); test('default notification icon avoids macOS tray template assets', () => { const seen: string[] = []; const path = resolveDefaultNotificationIconPath({ platform: 'darwin', resourcesPath: '/Applications/SubMiner.app/Contents/Resources', appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar', dirname: '/Applications/SubMiner.app/Contents/Resources/app.asar/dist/core/utils', cwd: '/Applications/SubMiner.app/Contents/Resources', joinPath: (...parts) => parts.join('/'), fileExists: (candidate) => { seen.push(candidate); return candidate.endsWith('/assets/SubMiner-square.png'); }, }); assert.equal(path, '/Applications/SubMiner.app/Contents/Resources/assets/SubMiner-square.png'); assert.equal( seen.some((candidate) => candidate.includes('SubMinerTemplate')), false, ); }); test('notify-send replacer reuses the daemon-assigned id for follow-up updates', () => { const calls: string[][] = []; const replacer = createNotifySendReplacer((args, callback) => { calls.push(args); callback(null, '42\n'); }); replacer('sync', { title: 'SubMiner', body: 'Generating 1/3' }, () => assert.fail('no fallback')); replacer('sync', { title: 'SubMiner', body: 'Generating 2/3' }, () => assert.fail('no fallback')); assert.equal(calls.length, 2); assert.equal(calls[0]?.includes('--print-id'), true); assert.equal( calls[0]?.some((arg) => arg.startsWith('--replace-id=')), false, ); assert.equal(calls[1]?.includes('--replace-id=42'), true); }); test('notify-send replacer collapses a mid-send burst to the latest update', () => { const pending: Array<(error: Error | null, stdout: string) => void> = []; const calls: string[][] = []; const replacer = createNotifySendReplacer((args, callback) => { calls.push(args); pending.push(callback); }); replacer('sync', { title: 'SubMiner', body: 'first' }, () => assert.fail('no fallback')); replacer('sync', { title: 'SubMiner', body: 'second' }, () => assert.fail('no fallback')); replacer('sync', { title: 'SubMiner', body: 'third' }, () => assert.fail('no fallback')); // Only one send is in flight, so the id capture cannot race. assert.equal(calls.length, 1); assert.equal(calls[0]?.at(-1), 'first'); pending[0]?.(null, '7'); // 'second' is stale by the time the daemon frees up, so only 'third' is sent. assert.equal(calls.length, 2); assert.equal(calls[1]?.at(-1), 'third'); assert.equal(calls[1]?.includes('--replace-id=7'), true); pending[1]?.(null, '7'); assert.equal(calls.length, 2); }); test('notify-send replacer passes the resolved icon path through', () => { const calls: string[][] = []; const replacer = createNotifySendReplacer((args, callback) => { calls.push(args); callback(null, '3'); }); replacer( 'sync', { title: 'SubMiner', body: 'Generating', iconPath: '/opt/SubMiner/assets/SubMiner-square.png' }, () => assert.fail('no fallback'), ); assert.equal(calls[0]?.includes('--icon=/opt/SubMiner/assets/SubMiner-square.png'), true); }); test('notify-send replacer tracks a separate daemon id per replaceId', () => { const calls: string[][] = []; let nextId = 10; const replacer = createNotifySendReplacer((args, callback) => { calls.push(args); callback(null, String(nextId++)); }); replacer('dictionary', { title: 'SubMiner', body: 'dict 1' }, () => assert.fail('no fallback')); replacer('startup', { title: 'SubMiner', body: 'startup 1' }, () => assert.fail('no fallback')); replacer('dictionary', { title: 'SubMiner', body: 'dict 2' }, () => assert.fail('no fallback')); replacer('startup', { title: 'SubMiner', body: 'startup 2' }, () => assert.fail('no fallback')); assert.equal(calls.length, 4); assert.equal(calls[2]?.includes('--replace-id=10'), true); assert.equal(calls[3]?.includes('--replace-id=11'), true); }); test('notify-send replacer escapes freedesktop body markup', () => { const calls: string[][] = []; const replacer = createNotifySendReplacer((args, callback) => { calls.push(args); callback(null, '1'); }); replacer('sync', { title: 'SubMiner', body: 'Steins;Gate <0 & more' }, () => {}); assert.equal(calls[0]?.at(-1), 'Steins;Gate <0 & more'); }); function spawnError(code: string): Error { return Object.assign(new Error(`spawn notify-send ${code}`), { code }); } function daemonError(): Error { // execFile reports a bad exit as a numeric code, unlike a libuv spawn failure. return Object.assign(new Error('notify-send exited with code 1'), { code: 1 }); } test('notify-send replacer falls back to Electron notifications permanently when the binary is missing', () => { let execCalls = 0; let fallbacks = 0; const replacer = createNotifySendReplacer((_args, callback) => { execCalls += 1; callback(spawnError('ENOENT'), ''); }); replacer('sync', { title: 'SubMiner', body: 'first' }, () => (fallbacks += 1)); replacer('sync', { title: 'SubMiner', body: 'second' }, () => (fallbacks += 1)); assert.equal(execCalls, 1); assert.equal(fallbacks, 2); }); test('notify-send replacer keeps using notify-send after a transient daemon failure', () => { let execCalls = 0; let fallbacks = 0; const replacer = createNotifySendReplacer((_args, callback) => { execCalls += 1; callback(execCalls === 1 ? daemonError() : null, '5'); }); replacer('sync', { title: 'SubMiner', body: 'first' }, () => (fallbacks += 1)); replacer('sync', { title: 'SubMiner', body: 'second' }, () => assert.fail('no fallback')); // One bad answer only costs that update; a busy daemon must not downgrade the rest of the run. assert.equal(execCalls, 2); assert.equal(fallbacks, 1); }); test('notify-send replacer retries after a transient spawn failure', () => { let execCalls = 0; let fallbacks = 0; const replacer = createNotifySendReplacer((_args, callback) => { execCalls += 1; // EMFILE means the process was out of descriptors, not that notify-send is unusable. callback(execCalls === 1 ? spawnError('EMFILE') : null, '5'); }); replacer('sync', { title: 'SubMiner', body: 'first' }, () => (fallbacks += 1)); replacer('sync', { title: 'SubMiner', body: 'second' }, () => assert.fail('no fallback')); assert.equal(execCalls, 2); assert.equal(fallbacks, 1); }); test('notify-send replacer gives up after a run of transient failures', () => { let execCalls = 0; let fallbacks = 0; const replacer = createNotifySendReplacer((_args, callback) => { execCalls += 1; callback(daemonError(), ''); }); for (let update = 0; update < 5; update += 1) { replacer('sync', { title: 'SubMiner', body: `update ${update}` }, () => (fallbacks += 1)); } // Three strikes, then every later update goes straight to Electron instead of waiting on a spawn. assert.equal(execCalls, 3); assert.equal(fallbacks, 5); }); test('notify-send replacer falls back with only the latest update when a send fails mid-flight', () => { let execCalls = 0; const fallbacks: string[] = []; const pending: Array<(error: Error | null, stdout: string) => void> = []; const replacer = createNotifySendReplacer((_args, callback) => { execCalls += 1; pending.push(callback); }); replacer('sync', { title: 'SubMiner', body: 'first' }, () => fallbacks.push('first')); replacer('sync', { title: 'SubMiner', body: 'second' }, () => fallbacks.push('second')); pending[0]?.(spawnError('ENOENT'), ''); assert.equal(execCalls, 1); // The queued update still reaches the user, and the superseded one is dropped rather than // flashing a stale message ahead of it. assert.deepEqual(fallbacks, ['second']); }); test('notify-send replacer survives a synchronous spawn throw', () => { let execCalls = 0; let fallbacks = 0; const replacer = createNotifySendReplacer(() => { execCalls += 1; throw new Error('spawn threw synchronously'); }); replacer('sync', { title: 'SubMiner', body: 'first' }, () => (fallbacks += 1)); // A wedged entry would silently drop every later update, so the fallback must still fire. replacer('sync', { title: 'SubMiner', body: 'second' }, () => (fallbacks += 1)); assert.equal(execCalls, 1); assert.equal(fallbacks, 2); }); test('default notification icon resolves cwd fallback through injected deps', () => { const resolvedPath = resolveDefaultNotificationIconPath({ platform: 'linux', resourcesPath: '/missing/resources', appPath: '/missing/app', dirname: '/missing/dist/core/utils', cwd: '/portable/SubMiner', joinPath: (...parts) => parts.join('/'), fileExists: (candidate) => candidate === '/portable/SubMiner/assets/SubMiner-square.png', }); assert.equal(resolvedPath, '/portable/SubMiner/assets/SubMiner-square.png'); });