mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)
This commit is contained in:
@@ -1,24 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Args } from '../types.js';
|
||||
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||
|
||||
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||
function makeContext(
|
||||
overrides: Partial<Args>,
|
||||
appPath: string | null = '/opt/SubMiner/subminer-app',
|
||||
): LauncherCommandContext {
|
||||
return {
|
||||
args: {
|
||||
sync: true,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
logLevel: 'warn',
|
||||
...overrides,
|
||||
} as Args,
|
||||
@@ -26,232 +19,90 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '',
|
||||
pluginRuntimeConfig: {},
|
||||
appPath: null,
|
||||
appPath,
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: process,
|
||||
processAdapter: { platform: () => 'linux' },
|
||||
} as unknown as LauncherCommandContext;
|
||||
}
|
||||
|
||||
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
|
||||
return { status: 0, stdout, stderr: '' };
|
||||
}
|
||||
|
||||
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
|
||||
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
|
||||
context.mpvSocketPath = '/tmp/subminer-socket';
|
||||
let socketConnectable = false;
|
||||
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
|
||||
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
|
||||
findLiveStatsDaemonPid: () => null,
|
||||
canConnectUnixSocket: async () => socketConnectable,
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
|
||||
|
||||
socketConnectable = true;
|
||||
await assert.rejects(
|
||||
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
|
||||
/mpv\/SubMiner session appears to be running/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (dbPath: string, outPath: string) => {
|
||||
calls.push(`snapshot:${dbPath}->${outPath}`);
|
||||
},
|
||||
mergeSnapshotIntoDb: (dbPath: string, snapshotPath: string) => {
|
||||
calls.push(`merge:${dbPath}<-${snapshotPath}`);
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
formatMergeSummary: () => 'summary',
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
assertSafeSshHost: (host: string) => {
|
||||
calls.push(`host:${host}`);
|
||||
},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runSsh: (_host: string, command: string) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
return command.startsWith('mktemp ') ? ok('/tmp/subminer-sync.remote\n') : ok();
|
||||
},
|
||||
runScp: (from: string, to: string) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
},
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
runAppCommand: (appPath, appArgs) => {
|
||||
spawned.push({ appPath, appArgs });
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
||||
deps,
|
||||
),
|
||||
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
|
||||
true,
|
||||
);
|
||||
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
|
||||
assert.deepEqual(spawned, [
|
||||
{
|
||||
appPath: '/opt/SubMiner/subminer-app',
|
||||
appArgs: ['--sync-cli', 'sync', 'media-box', '--json', '--log-level', 'warn'],
|
||||
},
|
||||
]);
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
|
||||
deps,
|
||||
);
|
||||
assert.ok(calls.includes('quiescent'));
|
||||
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
|
||||
deps,
|
||||
);
|
||||
assert.ok(calls.includes('host:media-box'));
|
||||
|
||||
await assert.rejects(
|
||||
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
|
||||
/sync requires a host, --snapshot <file>, or --merge <file>/,
|
||||
);
|
||||
assert.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
|
||||
assert.equal(spawned.length, 1);
|
||||
});
|
||||
|
||||
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
|
||||
const calls: string[] = [];
|
||||
let localTmpDir = '';
|
||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
||||
const spawned: string[][] = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||
calls.push(`snapshot:${outPath}`);
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
mergeSnapshotIntoDb: () => {
|
||||
calls.push('local-merge');
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
formatMergeSummary: () => 'summary',
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
assertSafeSshHost: () => {},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
mkdtempSync: ((prefix: string) => {
|
||||
localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), path.basename(prefix)));
|
||||
return localTmpDir;
|
||||
}) as typeof fs.mkdtempSync,
|
||||
runSsh: (_host: string, command: string) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
|
||||
if (command.includes(' sync --snapshot ')) return ok();
|
||||
if (command.includes(' sync --merge ')) {
|
||||
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
runScp: (from: string, to: string) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
|
||||
runAppCommand: (_appPath, appArgs) => {
|
||||
spawned.push(appArgs);
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
|
||||
);
|
||||
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
|
||||
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(calls.includes('local-merge'));
|
||||
assert.ok(calls.some((call) => call.startsWith('ssh:rm -rf ')));
|
||||
assert.equal(fs.existsSync(localTmpDir), false);
|
||||
});
|
||||
|
||||
test('runHostSync includes remote snapshot stderr in failures', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
ensureTrackerQuiescent: async () => {},
|
||||
assertSafeSshHost: () => {},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runSsh: (_host: string, command: string) => {
|
||||
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
|
||||
if (command.includes(' sync --snapshot ')) {
|
||||
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
runScp: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
|
||||
);
|
||||
});
|
||||
|
||||
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
|
||||
return {
|
||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||
calls.push(`snapshot:${outPath}`);
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
mergeSnapshotIntoDb: () => {
|
||||
calls.push('local-merge');
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
formatMergeSummary: () => 'summary',
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
assertSafeSshHost: () => {},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runSsh: (_host: string, command: string) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
|
||||
return ok();
|
||||
},
|
||||
runScp: (from: string, to: string) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('runHostSync push only snapshots locally and merges remotely', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'push',
|
||||
syncCliTokens: [
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
],
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
makeDirectionDeps(calls),
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
|
||||
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
|
||||
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
|
||||
assert.ok(!calls.includes('local-merge'));
|
||||
assert.deepEqual(spawned, [
|
||||
[
|
||||
'--sync-cli',
|
||||
'sync',
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runHostSync pull only snapshots remotely and merges locally', async () => {
|
||||
const calls: string[] = [];
|
||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: () => {
|
||||
throw new Error('should not spawn');
|
||||
},
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'pull',
|
||||
}),
|
||||
makeDirectionDeps(calls),
|
||||
await assert.rejects(
|
||||
() => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
|
||||
/SubMiner app binary not found \(sync runs inside the app\)/,
|
||||
);
|
||||
|
||||
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
|
||||
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
|
||||
assert.ok(calls.includes('local-merge'));
|
||||
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user