fix: address latest PR review feedback

This commit is contained in:
2026-03-19 20:06:52 -07:00
parent 9ad3ccfa38
commit 1227706ac9
11 changed files with 306 additions and 33 deletions

View File

@@ -212,7 +212,7 @@ test('stats background command launches attached daemon control command with res
]);
});
test('stats command returns after startup response even if app process stays running', async () => {
test('stats command waits for attached app exit after startup response', async () => {
const context = createContext();
context.args.stats = true;
const forwarded: string[][] = [];
@@ -246,6 +246,24 @@ test('stats command returns after startup response even if app process stays run
]);
});
test('stats command throws when attached app exits non-zero after startup response', async () => {
const context = createContext();
context.args.stats = true;
await assert.rejects(async () => {
await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return 3;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
}, /Stats app exited with status 3\./);
});
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const context = createContext();
context.args.stats = true;

View File

@@ -123,7 +123,10 @@ export async function runStatsCommand(
if (!startupResult.response.ok) {
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
}
await attachedExitPromise;
const exitStatus = await attachedExitPromise;
if (exitStatus !== 0) {
throw new Error(`Stats app exited with status ${exitStatus}.`);
}
return true;
}
const attachedExitPromiseCleanup = attachedExitPromise;

View File

@@ -291,6 +291,12 @@ export function parseCliPrograms(
if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
}
if (
normalizedAction !== 'cleanup' &&
(options.vocab === true || options.lifetime === true)
) {
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
}
if (normalizedAction === 'cleanup') {
statsCleanup = true;
statsCleanupLifetime = options.lifetime === true;

View File

@@ -9,6 +9,7 @@ import type { Args } from './types';
import {
cleanupPlaybackSession,
findAppBinary,
parseMpvArgString,
runAppCommandCaptureOutput,
shouldResolveAniSkipMetadata,
startOverlay,
@@ -60,6 +61,16 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
}
});
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
'',
'--force-media-title',
'',
'--pause',
]);
});
test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath();
try {

View File

@@ -42,6 +42,7 @@ export function parseMpvArgString(input: string): string[] {
const chars = input;
const args: string[] = [];
let current = '';
let tokenStarted = false;
let inSingleQuote = false;
let inDoubleQuote = false;
let escaping = false;
@@ -52,6 +53,7 @@ export function parseMpvArgString(input: string): string[] {
const ch = chars[i] || '';
if (escaping) {
current += ch;
tokenStarted = true;
escaping = false;
continue;
}
@@ -61,6 +63,7 @@ export function parseMpvArgString(input: string): string[] {
inSingleQuote = false;
} else {
current += ch;
tokenStarted = true;
}
continue;
}
@@ -71,6 +74,7 @@ export function parseMpvArgString(input: string): string[] {
escaping = true;
} else {
current += ch;
tokenStarted = true;
}
continue;
}
@@ -79,33 +83,40 @@ export function parseMpvArgString(input: string): string[] {
continue;
}
current += ch;
tokenStarted = true;
continue;
}
if (ch === '\\') {
if (canEscape(chars[i + 1])) {
escaping = true;
tokenStarted = true;
} else {
current += ch;
tokenStarted = true;
}
continue;
}
if (ch === "'") {
tokenStarted = true;
inSingleQuote = true;
continue;
}
if (ch === '"') {
tokenStarted = true;
inDoubleQuote = true;
continue;
}
if (/\s/.test(ch)) {
if (current) {
if (tokenStarted) {
args.push(current);
current = '';
tokenStarted = false;
}
continue;
}
current += ch;
tokenStarted = true;
}
if (escaping) {
@@ -114,7 +125,7 @@ export function parseMpvArgString(input: string): string[] {
if (inSingleQuote || inDoubleQuote) {
fail('Could not parse mpv args: unmatched quote');
}
if (current) {
if (tokenStarted) {
args.push(current);
}

View File

@@ -2,6 +2,34 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from './config';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected parseArgs to exit');
}
test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
@@ -119,6 +147,15 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
assert.equal(parsed.statsCleanupLifetime, true);
});
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', '--vocab'], 'subminer', {});
});
assert.equal(error.code, 1);
assert.match(error.message, /exit:1/);
});
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});

View File

@@ -14,6 +14,20 @@ function makeFile(filePath: string): void {
fs.writeFileSync(filePath, '/* theme */');
}
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
value: platform,
});
try {
return callback();
} finally {
if (originalDescriptor) {
Object.defineProperty(process, 'platform', originalDescriptor);
}
}
}
test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => {
const originalExistsSync = fs.existsSync;
const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`;
@@ -24,7 +38,7 @@ test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when
return false;
};
const result = findRofiTheme('/usr/local/bin/subminer');
const result = withPlatform('linux', () => findRofiTheme('/usr/local/bin/subminer'));
assert.equal(result, targetPath);
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -44,7 +58,7 @@ test('findRofiTheme resolves /usr/share/SubMiner/themes/subminer.rasi when /usr/
return false;
};
const result = findRofiTheme('/usr/bin/subminer');
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, sharePath);
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -60,10 +74,14 @@ test('findRofiTheme resolves XDG_DATA_HOME/SubMiner/themes/subminer.rasi when se
const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`);
makeFile(themePath);
const result = findRofiTheme('/usr/bin/subminer');
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, themePath);
} finally {
process.env.XDG_DATA_HOME = originalXdgDataHome;
if (originalXdgDataHome !== undefined) {
process.env.XDG_DATA_HOME = originalXdgDataHome;
} else {
delete process.env.XDG_DATA_HOME;
}
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
@@ -78,7 +96,7 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X
const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`);
makeFile(themePath);
const result = findRofiTheme('/usr/bin/subminer');
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, themePath);
} finally {
os.homedir = originalHomedir;