mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
test: cut low-value tests, rewrite the rest around behavior, parallelize lanes (#285)
This commit is contained in:
1 parent
4b6842d034
commit
b48083ddd8
368 files changed
+18045
-60331
No files matched your search
+297
-120
@@ -1,128 +1,305 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Args } from './types.js';
|
||||
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildForwardedJellyfinAppArgs,
|
||||
buildRootSearchGroups,
|
||||
classifyJellyfinChildSelection,
|
||||
deriveJellyfinTokenStorePath,
|
||||
hasStoredJellyfinSession,
|
||||
parseEpisodePathFromDisplay,
|
||||
parseJellyfinErrorFromAppOutput,
|
||||
parseJellyfinItemsFromAppOutput,
|
||||
parseJellyfinLibrariesFromAppOutput,
|
||||
parseJellyfinPreviewAuthResponse,
|
||||
readUtf8FileAppendedSince,
|
||||
runJellyfinPlayMenuWithDeps,
|
||||
shouldRetryWithStartForNoRunningInstance,
|
||||
} from './jellyfin.js';
|
||||
import { makeLauncherArgs } from './test-support/args.js';
|
||||
import { withEnv } from './test-support/env.js';
|
||||
|
||||
function createArgs(): Args {
|
||||
return {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
youtubeBrowser: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: 'https://jellyfin.example.test',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
launchMode: 'normal',
|
||||
};
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
||||
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
||||
const calls: string[] = [];
|
||||
class HandoffComplete extends Error {}
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
createArgs(),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
loadLauncherJellyfinConfig: () => ({}),
|
||||
findRofiTheme: () => null,
|
||||
resolveJellyfinSelection: async () => 'item-123',
|
||||
resolveJellyfinSelectionViaApp: async () => {
|
||||
throw new Error('unexpected app-based selection');
|
||||
},
|
||||
hasStoredJellyfinSession: () => true,
|
||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
pathExists: () => false,
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => {
|
||||
calls.push('wait');
|
||||
return true;
|
||||
},
|
||||
launchMpvIdleDetached: async () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
resolveLauncherRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
runAppCommandWithInheritLogged: () => {
|
||||
calls.push('handoff');
|
||||
throw new Error('stop after handoff');
|
||||
},
|
||||
log: () => {},
|
||||
},
|
||||
),
|
||||
/stop after handoff/,
|
||||
);
|
||||
type PlayMenuDeps = NonNullable<Parameters<typeof runJellyfinPlayMenuWithDeps>[4]>;
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
||||
} finally {
|
||||
if (originalAccessToken === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
||||
}
|
||||
if (originalUserId === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Runs the play menu with fake deps and a direct env session. The app handoff never returns in
|
||||
* production, so the fake throws `HandoffComplete` after recording its args into `handoff`.
|
||||
*/
|
||||
async function runPlayMenu(options: {
|
||||
socketReady?: boolean;
|
||||
overrides?: Partial<PlayMenuDeps>;
|
||||
args?: Parameters<typeof makeLauncherArgs>[0];
|
||||
}) {
|
||||
const launches: string[] = [];
|
||||
const handoff: { appArgs: string[] | null } = { appArgs: null };
|
||||
const deps: PlayMenuDeps = {
|
||||
loadLauncherJellyfinConfig: () => ({}),
|
||||
findRofiTheme: () => null,
|
||||
resolveJellyfinSelection: async () => 'item-123',
|
||||
resolveJellyfinSelectionViaApp: async () => {
|
||||
throw new Error('unexpected app-based selection');
|
||||
},
|
||||
hasStoredJellyfinSession: () => true,
|
||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
pathExists: () => options.socketReady ?? false,
|
||||
ensureRuntimePluginReady: async () => {
|
||||
launches.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
launchMpvIdleDetached: async () => {
|
||||
launches.push('launch');
|
||||
},
|
||||
resolveLauncherRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||
runAppCommandWithInheritLogged: (_appPath, appArgs) => {
|
||||
handoff.appArgs = appArgs;
|
||||
throw new HandoffComplete();
|
||||
},
|
||||
log: () => {},
|
||||
...options.overrides,
|
||||
};
|
||||
|
||||
await withEnv(
|
||||
{ SUBMINER_JELLYFIN_ACCESS_TOKEN: 'token', SUBMINER_JELLYFIN_USER_ID: 'user' },
|
||||
() =>
|
||||
assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
makeLauncherArgs({ jellyfinServer: 'https://jellyfin.example.test', ...options.args }),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
deps,
|
||||
),
|
||||
HandoffComplete,
|
||||
),
|
||||
);
|
||||
|
||||
return { launches, appArgs: handoff.appArgs };
|
||||
}
|
||||
|
||||
test('Jellyfin playback launches the idle mpv with the runtime plugin, then hands the item to the app', async () => {
|
||||
const { launches, appArgs } = await runPlayMenu({});
|
||||
|
||||
assert.deepEqual(launches, ['plugin', 'launch']);
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
test('Jellyfin playback reuses a ready mpv socket instead of launching idle mpv', async () => {
|
||||
const { launches, appArgs } = await runPlayMenu({ socketReady: true });
|
||||
|
||||
assert.deepEqual(launches, []);
|
||||
assert.deepEqual(appArgs, ['--start', '--jellyfin-play', '--jellyfin-item-id=item-123']);
|
||||
});
|
||||
|
||||
test('parseJellyfinLibrariesFromAppOutput parses prefixed library lines', () => {
|
||||
const parsed = parseJellyfinLibrariesFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin library: Anime [lib1] (tvshows)
|
||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin library: Movies [lib2] (movies)
|
||||
`);
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
{ id: 'lib1', name: 'Anime', kind: 'tvshows' },
|
||||
{ id: 'lib2', name: 'Movies', kind: 'movies' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinItemsFromAppOutput parses item title/id/type tuples', () => {
|
||||
const parsed = parseJellyfinItemsFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - INFO - [main] Jellyfin item: Solo Leveling S01E10 [item-10] (Episode)
|
||||
[subminer] - 2026-03-01 13:10:35 - INFO - [main] Jellyfin item: Movie [Alt] [movie-1] (Movie)
|
||||
`);
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
{
|
||||
id: 'item-10',
|
||||
name: 'Solo Leveling S01E10',
|
||||
type: 'Episode',
|
||||
display: 'Solo Leveling S01E10',
|
||||
},
|
||||
{
|
||||
id: 'movie-1',
|
||||
name: 'Movie [Alt]',
|
||||
type: 'Movie',
|
||||
display: 'Movie [Alt]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildForwardedJellyfinAppArgs forces app log level for parseable list output', () => {
|
||||
const forwarded = buildForwardedJellyfinAppArgs(
|
||||
{
|
||||
jellyfinServer: 'https://jf.example.test/',
|
||||
passwordStore: 'gnome-libsecret',
|
||||
logLevel: 'info',
|
||||
} as never,
|
||||
['--jellyfin-libraries'],
|
||||
);
|
||||
|
||||
assert.deepEqual(forwarded, [
|
||||
'--jellyfin-libraries',
|
||||
'--jellyfin-server',
|
||||
'https://jf.example.test',
|
||||
'--password-store',
|
||||
'gnome-libsecret',
|
||||
'--log-level',
|
||||
'info',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts bracketed error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - WARN - [main] test warning
|
||||
[2026-03-01T21:11:28.821Z] [ERROR] Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
parsed,
|
||||
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseJellyfinErrorFromAppOutput extracts main runtime error lines', () => {
|
||||
const parsed = parseJellyfinErrorFromAppOutput(`
|
||||
[subminer] - 2026-03-01 13:10:34 - ERROR - [main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
parsed,
|
||||
'[main] runJellyfinCommand failed: {"message":"Missing Jellyfin password."}',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse parses valid structured response payload', () => {
|
||||
const parsed = parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
serverUrl: 'http://pve-main:8096/',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: 'token-123',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => {
|
||||
assert.equal(parseJellyfinPreviewAuthResponse(''), null);
|
||||
assert.equal(parseJellyfinPreviewAuthResponse('{not json}'), null);
|
||||
assert.equal(
|
||||
parseJellyfinPreviewAuthResponse(
|
||||
JSON.stringify({
|
||||
serverUrl: 'http://pve-main:8096',
|
||||
accessToken: '',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasStoredJellyfinSession checks token-store existence', () => {
|
||||
const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc');
|
||||
const tokenPath = deriveJellyfinTokenStorePath(configPath);
|
||||
const exists = (candidate: string): boolean => candidate === tokenPath;
|
||||
assert.equal(hasStoredJellyfinSession(configPath, exists), true);
|
||||
assert.equal(
|
||||
hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => {
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance('No running instance. Use --start to launch the app.'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRetryWithStartForNoRunningInstance(
|
||||
'Missing Jellyfin session. Run --jellyfin-login first.',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('readUtf8FileAppendedSince treats offset as bytes and survives multibyte logs', () => {
|
||||
withTempDir((root) => {
|
||||
const logPath = path.join(root, 'SubMiner.log');
|
||||
const prefix = '[subminer] こんにちは\n';
|
||||
const suffix = '[subminer] Jellyfin library: Movies [lib2] (movies)\n';
|
||||
fs.writeFileSync(logPath, `${prefix}${suffix}`, 'utf8');
|
||||
|
||||
const byteOffset = Buffer.byteLength(prefix, 'utf8');
|
||||
const fromByteOffset = readUtf8FileAppendedSince(logPath, byteOffset);
|
||||
assert.match(fromByteOffset, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
||||
|
||||
const fromBeyondEnd = readUtf8FileAppendedSince(logPath, byteOffset + 9999);
|
||||
assert.match(fromBeyondEnd, /Jellyfin library: Movies \[lib2\] \(movies\)/);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseEpisodePathFromDisplay extracts series and season from episode display titles', () => {
|
||||
assert.deepEqual(
|
||||
parseEpisodePathFromDisplay('KONOSUBA S01E03 A Panty Treasure in This Right Hand!'),
|
||||
{
|
||||
seriesName: 'KONOSUBA',
|
||||
seasonNumber: 1,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseEpisodePathFromDisplay('Frieren S2E10 Something'), {
|
||||
seriesName: 'Frieren',
|
||||
seasonNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseEpisodePathFromDisplay returns null for non-episode displays', () => {
|
||||
assert.equal(parseEpisodePathFromDisplay('Movie Title (Movie)'), null);
|
||||
assert.equal(parseEpisodePathFromDisplay('Just A Name'), null);
|
||||
});
|
||||
|
||||
test('buildRootSearchGroups excludes episodes and keeps containers/movies', () => {
|
||||
const groups = buildRootSearchGroups([
|
||||
{ id: 'series-1', name: 'The Eminence in Shadow', type: 'Series', display: 'x' },
|
||||
{ id: 'movie-1', name: 'Spirited Away', type: 'Movie', display: 'x' },
|
||||
{ id: 'episode-1', name: 'The Eminence in Shadow S01E01', type: 'Episode', display: 'x' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(groups, [
|
||||
{
|
||||
id: 'series-1',
|
||||
name: 'The Eminence in Shadow',
|
||||
type: 'Series',
|
||||
display: 'The Eminence in Shadow (Series)',
|
||||
},
|
||||
{
|
||||
id: 'movie-1',
|
||||
name: 'Spirited Away',
|
||||
type: 'Movie',
|
||||
display: 'Spirited Away (Movie)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifyJellyfinChildSelection keeps container drilldown state instead of flattening', () => {
|
||||
const next = classifyJellyfinChildSelection({ id: 'season-2', type: 'Season' });
|
||||
assert.deepEqual(next, {
|
||||
kind: 'container',
|
||||
id: 'season-2',
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user