mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
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');
|
|
});
|