mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
feat: stabilize startup sync and overlay/runtime paths
This commit is contained in:
@@ -159,6 +159,40 @@ test('stats command launches attached app command with response path', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command returns after startup response even if app process stays running', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
const forwarded: string[][] = [];
|
||||
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
||||
|
||||
const statsCommand = runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return started;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
const result = await Promise.race([
|
||||
statsCommand.then(() => 'resolved'),
|
||||
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 5)),
|
||||
]);
|
||||
|
||||
assert.equal(result, 'timeout');
|
||||
|
||||
const final = await statsCommand;
|
||||
assert.equal(final, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
@@ -189,6 +223,36 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () =
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
context.args.statsCleanup = true;
|
||||
context.args.statsCleanupLifetime = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-lifetime',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command throws when stats response reports an error', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
@@ -207,9 +271,11 @@ test('stats command throws when stats response reports an error', async () => {
|
||||
}, /Immersion tracking is disabled in config\./);
|
||||
});
|
||||
|
||||
test('stats command fails if attached app exits before startup response', async () => {
|
||||
test('stats cleanup command fails if attached app exits before startup response', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
context.args.statsCleanup = true;
|
||||
context.args.statsCleanupVocab = true;
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(context, {
|
||||
|
||||
@@ -24,13 +24,15 @@ type StatsCommandDeps = {
|
||||
removeDir: (targetPath: string) => void;
|
||||
};
|
||||
|
||||
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 8_000;
|
||||
|
||||
const defaultDeps: StatsCommandDeps = {
|
||||
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
||||
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
||||
waitForStatsResponse: async (responsePath) => {
|
||||
const deadline = Date.now() + 8000;
|
||||
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
@@ -71,20 +73,46 @@ export async function runStatsCommand(
|
||||
if (args.statsCleanupVocab) {
|
||||
forwarded.push('--stats-cleanup-vocab');
|
||||
}
|
||||
if (args.statsCleanupLifetime) {
|
||||
forwarded.push('--stats-cleanup-lifetime');
|
||||
}
|
||||
if (args.logLevel !== 'info') {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
const attachedExitPromise = deps.runAppCommandAttached(
|
||||
appPath,
|
||||
forwarded,
|
||||
args.logLevel,
|
||||
'stats',
|
||||
);
|
||||
const attachedExitPromise = deps.runAppCommandAttached(appPath, forwarded, args.logLevel, 'stats');
|
||||
|
||||
if (!args.statsCleanup) {
|
||||
const startupResult = await Promise.race([
|
||||
deps
|
||||
.waitForStatsResponse(responsePath)
|
||||
.then((response) => ({ kind: 'response' as const, response })),
|
||||
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||
]);
|
||||
if (startupResult.kind === 'exit') {
|
||||
if (startupResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Stats app exited before startup response (status ${startupResult.status}).`,
|
||||
);
|
||||
}
|
||||
const response = await deps.waitForStatsResponse(responsePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!startupResult.response.ok) {
|
||||
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
await attachedExitPromise;
|
||||
return true;
|
||||
}
|
||||
const attachedExitPromiseCleanup = attachedExitPromise;
|
||||
|
||||
const startupResult = await Promise.race([
|
||||
deps
|
||||
.waitForStatsResponse(responsePath)
|
||||
.then((response) => ({ kind: 'response' as const, response })),
|
||||
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||
attachedExitPromiseCleanup.then((status) => ({ kind: 'exit' as const, status })),
|
||||
]);
|
||||
if (startupResult.kind === 'exit') {
|
||||
if (startupResult.status !== 0) {
|
||||
@@ -101,7 +129,7 @@ export async function runStatsCommand(
|
||||
if (!startupResult.response.ok) {
|
||||
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
const exitStatus = await attachedExitPromise;
|
||||
const exitStatus = await attachedExitPromiseCleanup;
|
||||
if (exitStatus !== 0) {
|
||||
throw new Error(`Stats app exited with status ${exitStatus}.`);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
stats: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
@@ -194,6 +195,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.statsTriggered) parsed.stats = true;
|
||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
||||
if (invocations.dictionaryTarget) {
|
||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CliInvocations {
|
||||
statsTriggered: boolean;
|
||||
statsCleanup: boolean;
|
||||
statsCleanupVocab: boolean;
|
||||
statsCleanupLifetime: boolean;
|
||||
statsLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
@@ -145,6 +146,7 @@ export function parseCliPrograms(
|
||||
let statsTriggered = false;
|
||||
let statsCleanup = false;
|
||||
let statsCleanupVocab = false;
|
||||
let statsCleanupLifetime = false;
|
||||
let statsLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
@@ -253,14 +255,21 @@ export function parseCliPrograms(
|
||||
commandProgram
|
||||
.command('stats')
|
||||
.description('Launch the local immersion stats dashboard')
|
||||
.argument('[action]', 'cleanup')
|
||||
.argument('[action]', 'cleanup|rebuild|backfill')
|
||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
statsTriggered = true;
|
||||
if ((action || '').toLowerCase() === 'cleanup') {
|
||||
const normalizedAction = (action || '').toLowerCase();
|
||||
if (normalizedAction === 'cleanup') {
|
||||
statsCleanup = true;
|
||||
statsCleanupVocab = options.vocab !== false;
|
||||
statsCleanupLifetime = options.lifetime === true;
|
||||
statsCleanupVocab = statsCleanupLifetime ? false : options.vocab !== false;
|
||||
} else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
|
||||
statsCleanup = true;
|
||||
statsCleanupLifetime = true;
|
||||
statsCleanupVocab = false;
|
||||
}
|
||||
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
@@ -346,6 +355,7 @@ export function parseCliPrograms(
|
||||
statsTriggered,
|
||||
statsCleanup,
|
||||
statsCleanupVocab,
|
||||
statsCleanupLifetime,
|
||||
statsLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
|
||||
@@ -26,7 +26,9 @@ type RunResult = {
|
||||
};
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-test-'));
|
||||
// Keep paths short on macOS/Linux: Unix domain sockets have small path-length limits.
|
||||
const tmpBase = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
||||
const dir = fs.mkdtempSync(path.join(tmpBase, 'subminer-launcher-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
@@ -279,8 +281,8 @@ for arg in "$@"; do
|
||||
;;
|
||||
esac
|
||||
done
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
|
||||
`,
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if(socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if(socket) fs.rmSync(socket,{force:true}); }catch{} const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); if(!socket) process.exit(0); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||
@@ -391,6 +393,54 @@ exit 0
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'stats command tolerates slower dashboard startup before timing out',
|
||||
{ timeout: 20000 },
|
||||
() => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer-slow.sh');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
response_path=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--stats-response-path" ]; then
|
||||
response_path="$arg"
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--stats-response-path=*)
|
||||
response_path="\${arg#--stats-response-path=}"
|
||||
;;
|
||||
--stats-response-path)
|
||||
prev="--stats-response-path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
sleep 9
|
||||
mkdir -p "$(dirname "$response_path")"
|
||||
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
};
|
||||
const result = runLauncher(['stats'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -81,3 +81,21 @@ test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps lifetime stats cleanup flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, false);
|
||||
assert.equal(parsed.statsCleanupLifetime, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
|
||||
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, false);
|
||||
assert.equal(parsed.statsCleanupLifetime, true);
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface Args {
|
||||
stats: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
configPath: boolean;
|
||||
|
||||
Reference in New Issue
Block a user