import test from 'node:test'; import assert from 'node:assert/strict'; import { buildJellyfinSetupSubmissionUrl, buildJellyfinSetupFormHtml, buildJellyfinSetupViewState, createHandleJellyfinSetupNavigationHandler, createHandleJellyfinSetupSubmissionHandler, createMaybeFocusExistingJellyfinSetupWindowHandler, createOpenJellyfinSetupWindowHandler, normalizeJellyfinSetupIpcSubmission, parseJellyfinSetupSubmissionUrl, } from './jellyfin-setup-window'; test('buildJellyfinSetupFormHtml escapes default values', () => { const html = buildJellyfinSetupFormHtml({ selectedServerUrl: 'http://host/"x"', username: 'user"name', hasStoredSession: true, statusMessage: 'Ready "now"', statusKind: 'success', }); assert.ok(html.includes('http://host/"x"')); assert.ok(html.includes('user"name')); assert.ok(html.includes('Ready "now"')); assert.ok(html.includes('Logout')); assert.ok(html.includes('window.subminerJellyfinSetup')); assert.ok(html.includes('Logging in to Jellyfin')); assert.ok(html.includes('subminer://jellyfin-setup?')); assert.equal(html.includes('params.set("password", passwordValue)'), false); assert.ok(html.includes('window.__subminerJellyfinPassword = passwordValue')); }); test('buildJellyfinSetupViewState prefills configured server URL', () => { const state = buildJellyfinSetupViewState({ config: { serverUrl: ' http://configured:8096/ ', username: 'alice', recentServers: ['http://recent:8096', 'http://configured:8096', ''], }, defaultServerUrl: 'http://127.0.0.1:8096', hasStoredSession: false, }); assert.equal(state.selectedServerUrl, 'http://configured:8096'); assert.equal(state.username, 'alice'); assert.equal(state.statusKind, 'idle'); }); test('buildJellyfinSetupViewState falls back to recent server URL', () => { const state = buildJellyfinSetupViewState({ config: { serverUrl: '', username: 'alice', recentServers: ['http://recent:8096'], }, defaultServerUrl: 'http://127.0.0.1:8096', hasStoredSession: false, }); assert.equal(state.selectedServerUrl, 'http://recent:8096'); }); test('maybe focus jellyfin setup window no-ops without window', () => { const handler = createMaybeFocusExistingJellyfinSetupWindowHandler({ getSetupWindow: () => null, }); const handled = handler(); assert.equal(handled, false); }); test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => { const parsed = parseJellyfinSetupSubmissionUrl( 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a&password=b', ); assert.deepEqual(parsed, { action: 'login', server: 'http://localhost', username: 'a', password: 'b', }); assert.deepEqual(parseJellyfinSetupSubmissionUrl('subminer://jellyfin-setup?action=logout'), { action: 'logout', server: '', username: '', password: '', }); assert.deepEqual(parseJellyfinSetupSubmissionUrl('subminer://jellyfin-setup?action=done'), { action: 'done', server: '', username: '', password: '', }); assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null); }); test('jellyfin setup ipc submissions normalize to password-free setup urls', () => { const submission = normalizeJellyfinSetupIpcSubmission({ action: 'login', server: 'http://localhost:8096', username: 'alice', password: 'secret', }); assert.deepEqual(submission, { action: 'login', server: 'http://localhost:8096', username: 'alice', password: 'secret', }); if (!submission) { throw new Error('missing normalized submission'); } const setupUrl = buildJellyfinSetupSubmissionUrl(submission); assert.equal( setupUrl, 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost%3A8096&username=alice', ); assert.equal(setupUrl.includes('secret'), false); assert.deepEqual(normalizeJellyfinSetupIpcSubmission({ action: 'done' }), { action: 'done', server: '', username: '', password: '', }); assert.equal(normalizeJellyfinSetupIpcSubmission('bad'), null); }); test('createHandleJellyfinSetupSubmissionHandler applies successful login', async () => { const calls: string[] = []; let patchPayload: unknown = null; let savedSession: unknown = null; let authPassword = ''; const handler = createHandleJellyfinSetupSubmissionHandler({ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: async (_server, _username, password) => { authPassword = password; return { serverUrl: 'http://localhost', username: 'user', accessToken: 'token', userId: 'uid', }; }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did', }), saveStoredSession: (session) => { savedSession = session; calls.push('save'); }, clearStoredSession: () => calls.push('clear'), patchJellyfinConfig: (session) => { patchPayload = session; calls.push('patch'); }, restartRemoteSession: async () => { calls.push('restart-remote'); }, logInfo: () => calls.push('info'), logError: () => calls.push('error'), showMpvOsd: (message) => calls.push(`osd:${message}`), closeSetupWindow: () => calls.push('close'), reloadSetupWindow: () => calls.push('reload'), }); const handled = await handler( 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a', 'b', ); assert.equal(handled, true); assert.deepEqual(calls, [ 'save', 'patch', 'restart-remote', 'info', 'osd:Jellyfin login success', 'reload', ]); assert.equal(authPassword, 'b'); assert.deepEqual(savedSession, { accessToken: 'token', userId: 'uid' }); assert.deepEqual(patchPayload, { serverUrl: 'http://localhost', username: 'user', accessToken: 'token', userId: 'uid', }); }); test('createHandleJellyfinSetupSubmissionHandler reports failure to OSD', async () => { const calls: string[] = []; const handler = createHandleJellyfinSetupSubmissionHandler({ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: async () => { throw new Error('bad credentials'); }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did', }), saveStoredSession: () => calls.push('save'), clearStoredSession: () => calls.push('clear'), patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: () => calls.push('error'), showMpvOsd: (message) => calls.push(`osd:${message}`), closeSetupWindow: () => calls.push('close'), reloadSetupWindow: (_state) => calls.push('reload'), }); const handled = await handler( 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a&password=b', ); assert.equal(handled, true); assert.deepEqual(calls, ['error', 'osd:Jellyfin login failed: bad credentials', 'reload']); }); test('createHandleJellyfinSetupSubmissionHandler reports logout failure inline', async () => { const calls: string[] = []; let reloadState: unknown = null; const handler = createHandleJellyfinSetupSubmissionHandler({ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: async () => { throw new Error('should not authenticate'); }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did', }), saveStoredSession: () => calls.push('save'), clearStoredSession: () => { throw new Error('logout failed'); }, patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: (message) => calls.push(`error:${message}`), showMpvOsd: (message) => calls.push(`osd:${message}`), closeSetupWindow: () => calls.push('close'), reloadSetupWindow: (state) => { reloadState = state; calls.push('reload'); }, }); assert.equal(await handler('subminer://jellyfin-setup?action=logout'), true); assert.deepEqual(calls, [ 'error:Jellyfin logout failed', 'osd:Jellyfin logout failed: logout failed', 'reload', ]); assert.deepEqual(reloadState, { statusMessage: 'logout failed', statusKind: 'error', }); }); test('createHandleJellyfinSetupSubmissionHandler ignores concurrent login submissions', async () => { const calls: string[] = []; type TestSession = { serverUrl: string; username: string; accessToken: string; userId: string; }; let finishAuth: ((session: TestSession) => void) | undefined; const handler = createHandleJellyfinSetupSubmissionHandler({ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: async () => new Promise((resolve) => { finishAuth = resolve; }), getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did', }), saveStoredSession: () => calls.push('save'), clearStoredSession: () => calls.push('clear'), patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: () => calls.push('error'), showMpvOsd: (message) => calls.push(`osd:${message}`), closeSetupWindow: () => calls.push('close'), reloadSetupWindow: (state) => calls.push(`reload:${state?.statusKind || 'none'}`), }); const first = handler( 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a', 'first', ); const second = await handler( 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=a', 'second', ); assert.equal(second, true); const resolveAuth = finishAuth; if (!resolveAuth) { throw new Error('missing auth resolver'); } resolveAuth({ serverUrl: 'http://localhost', username: 'a', accessToken: 'token', userId: 'uid', }); assert.equal(await first, true); assert.deepEqual(calls, [ 'osd:Jellyfin login already in progress', 'reload:loading', 'save', 'patch', 'info', 'osd:Jellyfin login success', 'reload:success', ]); }); test('createHandleJellyfinSetupSubmissionHandler handles logout and done', async () => { const calls: string[] = []; const handler = createHandleJellyfinSetupSubmissionHandler({ parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: async () => { throw new Error('should not authenticate'); }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did', }), saveStoredSession: () => calls.push('save'), clearStoredSession: () => calls.push('clear'), patchJellyfinConfig: () => calls.push('patch'), stopRemoteSession: () => calls.push('stop-remote'), logInfo: (message) => calls.push(message), logError: () => calls.push('error'), showMpvOsd: (message) => calls.push(`osd:${message}`), closeSetupWindow: () => calls.push('close'), reloadSetupWindow: () => calls.push('reload'), }); assert.equal(await handler('subminer://jellyfin-setup?action=logout'), true); assert.equal(await handler('subminer://jellyfin-setup?action=done'), true); assert.deepEqual(calls, [ 'clear', 'stop-remote', 'Cleared stored Jellyfin auth session.', 'osd:Jellyfin logged out', 'reload', 'close', ]); }); test('createHandleJellyfinSetupNavigationHandler ignores unrelated urls', () => { const handleNavigation = createHandleJellyfinSetupNavigationHandler({ setupSchemePrefix: 'subminer://jellyfin-setup', handleSubmission: async () => {}, logError: () => {}, }); let prevented = false; const handled = handleNavigation({ url: 'https://example.com', preventDefault: () => { prevented = true; }, }); assert.equal(handled, false); assert.equal(prevented, false); }); test('createHandleJellyfinSetupNavigationHandler intercepts setup urls', async () => { const submittedUrls: string[] = []; const handleNavigation = createHandleJellyfinSetupNavigationHandler({ setupSchemePrefix: 'subminer://jellyfin-setup', handleSubmission: async (rawUrl) => { submittedUrls.push(rawUrl); }, logError: () => {}, }); let prevented = false; const handled = handleNavigation({ url: 'subminer://jellyfin-setup?server=http%3A%2F%2F127.0.0.1%3A8096', preventDefault: () => { prevented = true; }, }); await Promise.resolve(); assert.equal(handled, true); assert.equal(prevented, true); assert.equal(submittedUrls.length, 1); }); type NavigateHandler = (event: { preventDefault: () => void }, url: string) => void; function makeFakeWindow(calls: string[], options: { pagePassword?: string } = {}) { const hooks: { willNavigate?: NavigateHandler; closed?: () => void } = {}; const window = { focus: () => {}, webContents: { on: (event: 'will-navigate', handler: NavigateHandler) => { if (event === 'will-navigate') hooks.willNavigate = handler; }, executeJavaScript: async () => { if (options.pagePassword === undefined) { throw new Error('bridge path should not read from page'); } return options.pagePassword; }, }, loadURL: (url: string) => { calls.push(`load:${url.startsWith('data:text/html;charset=utf-8,') ? 'data-url' : 'other'}`); }, on: (event: 'closed', handler: () => void) => { if (event === 'closed') hooks.closed = handler; }, isDestroyed: () => false, close: () => calls.push('close'), }; return { window, hooks }; } type FakeWindow = ReturnType['window']; type OpenDeps = Parameters>[0]; /** Open-handler deps that record every side effect into `calls`; override per test. */ function makeOpenDeps( calls: string[], window: FakeWindow, overrides: Partial = {}, ): OpenDeps { return { maybeFocusExistingSetupWindow: () => false, createSetupWindow: () => window, getResolvedJellyfinConfig: () => ({ serverUrl: 'http://localhost:8096', username: 'alice', recentServers: [], }), buildSetupFormHtml: (state) => `${state.selectedServerUrl}|${state.username}`, parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl), authenticateWithPassword: async (_server, _username, password) => { calls.push(`password:${password}`); return { serverUrl: 'http://localhost:8096', username: 'alice', accessToken: 'token', userId: 'uid', }; }, getJellyfinClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'did', }), saveStoredSession: () => calls.push('save'), clearStoredSession: () => calls.push('clear'), patchJellyfinConfig: () => calls.push('patch'), logInfo: () => calls.push('info'), logError: () => calls.push('error'), showMpvOsd: (message) => calls.push(`osd:${message}`), clearSetupWindow: () => calls.push('clear-window'), setSetupWindow: () => calls.push('set-window'), encodeURIComponent: (value) => encodeURIComponent(value), defaultServerUrl: 'http://127.0.0.1:8096', hasStoredSession: () => false, ...overrides, }; } test('createOpenJellyfinSetupWindowHandler no-ops when existing setup window is focused', () => { const calls: string[] = []; const { window } = makeFakeWindow(calls); const handler = createOpenJellyfinSetupWindowHandler( makeOpenDeps(calls, window, { maybeFocusExistingSetupWindow: () => { calls.push('focus-existing'); return true; }, createSetupWindow: () => { calls.push('create-window'); throw new Error('should not create'); }, }), ); handler(); assert.deepEqual(calls, ['focus-existing']); }); test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window lifecycle', async () => { const calls: string[] = []; let prevented = false; const { window, hooks } = makeFakeWindow(calls, { pagePassword: 'pass' }); const handler = createOpenJellyfinSetupWindowHandler(makeOpenDeps(calls, window)); handler(); assert.deepEqual(calls.slice(0, 2), ['load:data-url', 'set-window']); assert.ok(hooks.willNavigate); assert.ok(hooks.closed); hooks.willNavigate?.( { preventDefault: () => { prevented = true; }, }, 'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost&username=alice', ); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(prevented, true); assert.ok(calls.includes('password:pass')); assert.ok(calls.includes('save')); assert.ok(calls.includes('patch')); assert.ok(calls.includes('osd:Jellyfin login success')); hooks.closed?.(); assert.ok(calls.includes('clear-window')); }); test('createOpenJellyfinSetupWindowHandler handles ipc bridge submissions', async () => { const bridge: { handler?: (payload: unknown) => Promise<{ handled: boolean }> } = {}; const calls: string[] = []; const { window, hooks } = makeFakeWindow(calls); const handler = createOpenJellyfinSetupWindowHandler( makeOpenDeps(calls, window, { registerSetupIpcHandler: (nextHandler) => { bridge.handler = nextHandler; calls.push('register-ipc'); return () => calls.push('unregister-ipc'); }, }), ); handler(); const bridgeHandler = bridge.handler; if (!bridgeHandler) { throw new Error('missing bridge handler'); } assert.deepEqual(await bridgeHandler('bad'), { handled: false, statusMessage: 'Invalid Jellyfin setup request.', statusKind: 'error', }); assert.equal(calls.includes('password:'), false); calls.length = 0; assert.deepEqual( await bridgeHandler({ action: 'login', server: 'http://localhost:8096', username: 'alice', password: 'secret', }), { handled: true }, ); assert.ok(calls.includes('password:secret')); assert.ok(calls.includes('save')); assert.ok(calls.includes('patch')); hooks.closed?.(); assert.ok(calls.includes('unregister-ipc')); assert.ok(calls.includes('clear-window')); });