mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-28 04:49:39 +00:00
3fbd0742be
Keep explicit media requests on yt-dlp's configured browser-cookie path and preserve cookies for normal captures.
863 lines
32 KiB
TypeScript
863 lines
32 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
|
import { useSettingsStore } from './useSettingsStore';
|
|
import * as ipc from '../ipc';
|
|
|
|
vi.mock('../ipc', () => ({
|
|
invokeCommand: vi.fn(),
|
|
}));
|
|
|
|
// Mock window.__TAURI_INTERNALS__ and log to prevent errors
|
|
vi.mock('../utils/logger', () => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@tauri-apps/api/path', () => ({
|
|
homeDir: vi.fn().mockResolvedValue('/Users/test'),
|
|
join: vi.fn(async (...parts: string[]) =>
|
|
parts
|
|
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
|
|
.join('/')
|
|
),
|
|
}));
|
|
|
|
vi.mock('./useSettingsStore', () => ({
|
|
useSettingsStore: {
|
|
getState: vi.fn(() => ({
|
|
proxyMode: 'none',
|
|
siteLogins: [],
|
|
globalSpeedLimit: '',
|
|
speedLimitPresetValues: [1, 5, 10],
|
|
logsEnabled: false,
|
|
perServerConnections: 16,
|
|
customUserAgent: '',
|
|
maxAutomaticRetries: 3,
|
|
mediaCookieSource: 'none',
|
|
baseDownloadFolder: '~/Downloads',
|
|
categorySubfoldersEnabled: true,
|
|
categorySubfolders: {
|
|
Musics: 'Musics',
|
|
Movies: 'Movies',
|
|
Compressed: 'Compressed',
|
|
Documents: 'Documents',
|
|
Pictures: 'Pictures',
|
|
Applications: 'Applications',
|
|
Other: 'Other',
|
|
},
|
|
categoryDirectoryOverrides: {},
|
|
})),
|
|
}
|
|
}));
|
|
|
|
describe('useDownloadStore', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
|
proxyMode: 'none',
|
|
siteLogins: [],
|
|
globalSpeedLimit: '',
|
|
speedLimitPresetValues: [1, 5, 10],
|
|
logsEnabled: false,
|
|
perServerConnections: 16,
|
|
customUserAgent: '',
|
|
maxAutomaticRetries: 3,
|
|
mediaCookieSource: 'none',
|
|
baseDownloadFolder: '~/Downloads',
|
|
categorySubfoldersEnabled: true,
|
|
categorySubfolders: {
|
|
Musics: 'Musics',
|
|
Movies: 'Movies',
|
|
Compressed: 'Compressed',
|
|
Documents: 'Documents',
|
|
Pictures: 'Pictures',
|
|
Applications: 'Applications',
|
|
Other: 'Other',
|
|
},
|
|
categoryDirectoryOverrides: {},
|
|
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
|
useDownloadStore.setState({
|
|
downloads: [],
|
|
backendRegisteredIds: new Set(),
|
|
pendingOrder: [],
|
|
isAddModalOpen: false,
|
|
pendingAddUrls: '',
|
|
pendingAddReferer: '',
|
|
pendingAddFilename: '',
|
|
pendingAddHeaders: '',
|
|
pendingAddCookies: '',
|
|
pendingAddMediaUrls: [],
|
|
pendingAddRequestContexts: {},
|
|
pendingAddRequestVersion: 0,
|
|
pendingAddLatestUrls: '',
|
|
});
|
|
});
|
|
|
|
it('normalizes proxy settings for download dispatch', async () => {
|
|
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
|
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
|
expect(normalizeCustomProxy(' socks5://127.0.0.1 ', 1080)).toBeNull();
|
|
expect(normalizeCustomProxy('https://proxy.local', 8443)).toBeNull();
|
|
expect(normalizeCustomProxy('127.0.0.1', NaN)).toBeNull();
|
|
|
|
expect(await getProxyArgs({
|
|
proxyMode: 'none',
|
|
proxyHost: '',
|
|
proxyPort: 8080
|
|
} as ReturnType<typeof useSettingsStore.getState>)).toBe('none');
|
|
|
|
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(null);
|
|
expect(await getProxyArgs({
|
|
proxyMode: 'system',
|
|
proxyHost: '',
|
|
proxyPort: 8080
|
|
} as ReturnType<typeof useSettingsStore.getState>)).toBe('none');
|
|
|
|
expect(await getProxyArgs({
|
|
proxyMode: 'custom',
|
|
proxyHost: 'http://127.0.0.1',
|
|
proxyPort: 1080
|
|
} as ReturnType<typeof useSettingsStore.getState>)).toBe('http://127.0.0.1:1080');
|
|
});
|
|
|
|
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
|
|
const settings = {
|
|
siteLogins: [
|
|
{ id: 'host', urlPattern: 'example.com', username: 'host' },
|
|
{ id: 'wildcard', urlPattern: '*.cdn.example.com', username: 'wildcard' },
|
|
{ id: 'broad', urlPattern: '*.example.com', username: 'broad' },
|
|
{ id: 'path', urlPattern: 'secure.example.com/private/*', username: 'path' },
|
|
{ id: 'url', urlPattern: 'https://downloads.example.net/releases/*', username: 'url' }
|
|
]
|
|
} as ReturnType<typeof useSettingsStore.getState>;
|
|
|
|
expect(getSiteLogin('https://example.com/file.zip', settings)?.id).toBe('host');
|
|
expect(getSiteLogin('https://assets.cdn.example.com/file.zip', settings)?.id).toBe('wildcard');
|
|
expect(getSiteLogin('https://secure.example.com/private/file.zip', settings)?.id).toBe('path');
|
|
expect(getSiteLogin('https://downloads.example.net/releases/app.zip', settings)?.id).toBe('url');
|
|
expect(getSiteLogin('https://secure.example.com/public/file.zip', settings)?.id).toBe('broad');
|
|
expect(getSiteLogin('https://unrelated.example.org/public/file.zip', settings)).toBeNull();
|
|
});
|
|
|
|
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
|
{ id: '2', url: 'http://test2', fileName: 'f2', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
|
] as any[],
|
|
backendRegisteredIds: new Set(['1']), // 1 is already registered, so it skips dispatch
|
|
pendingOrder: ['1'],
|
|
});
|
|
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'get_pending_order') return ['1', '2'];
|
|
return undefined;
|
|
});
|
|
|
|
const dispatched = await useDownloadStore.getState().startQueue('MAIN');
|
|
expect(dispatched).toEqual(['1', '2']);
|
|
|
|
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
|
const enqueues = calls.filter(c => c[0] === 'enqueue_download');
|
|
expect(enqueues.length).toBe(1);
|
|
expect((enqueues[0] as any)[1].item.id).toBe('2');
|
|
});
|
|
|
|
it('repairs stale queued backend registrations before accepting a queue start', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'stale', url: 'http://test', fileName: 'f', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
|
] as any[],
|
|
backendRegisteredIds: new Set(['stale']),
|
|
pendingOrder: [],
|
|
});
|
|
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'resume_download') return false;
|
|
if (cmd === 'get_pending_order') return ['stale'];
|
|
return undefined;
|
|
});
|
|
|
|
expect(await useDownloadStore.getState().startQueue('MAIN')).toEqual(['stale']);
|
|
|
|
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
|
expect(calls.some(call => call[0] === 'resume_download')).toBe(true);
|
|
expect(calls.some(call => call[0] === 'enqueue_download')).toBe(true);
|
|
});
|
|
|
|
it('does not overwrite a downloading event received while starting a queue', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
|
] as any[],
|
|
});
|
|
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'enqueue_download') {
|
|
useDownloadStore.getState().updateDownload('1', {
|
|
status: 'downloading',
|
|
speed: '1 MB/s',
|
|
eta: '10s'
|
|
});
|
|
}
|
|
if (cmd === 'get_pending_order') return ['1'];
|
|
return undefined;
|
|
});
|
|
|
|
expect(await useDownloadStore.getState().startQueue('MAIN')).toEqual(['1']);
|
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
|
status: 'downloading',
|
|
speed: '1 MB/s',
|
|
eta: '10s',
|
|
hasBeenDispatched: true
|
|
});
|
|
});
|
|
|
|
it('does not resurrect a row removed while its backend enqueue is in flight', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'late', url: 'http://test', fileName: 'late.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
|
] as any[],
|
|
});
|
|
|
|
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
|
|
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
|
resolveEnqueue = resolve;
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
|
if (command === 'enqueue_download') return enqueue as never;
|
|
if (command === 'get_pending_order') return Promise.resolve(['late']) as never;
|
|
return Promise.resolve(undefined) as never;
|
|
});
|
|
|
|
const start = useDownloadStore.getState().startQueue('MAIN');
|
|
await vi.waitFor(() => {
|
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
|
'enqueue_download',
|
|
expect.objectContaining({ item: expect.objectContaining({ id: 'late' }) })
|
|
);
|
|
});
|
|
|
|
await useDownloadStore.getState().removeDownload('late');
|
|
resolveEnqueue({ id: 'late', filename: 'late.bin' });
|
|
|
|
await expect(start).resolves.toEqual([]);
|
|
expect(useDownloadStore.getState().downloads).toEqual([]);
|
|
expect(useDownloadStore.getState().backendRegisteredIds.has('late')).toBe(false);
|
|
expect(
|
|
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it('re-enqueues the edited values only after an obsolete queued dispatch is removed', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'edited', url: 'http://test', fileName: 'old.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
|
] as any[],
|
|
});
|
|
|
|
let resolveFirstEnqueue!: (value: { id: string; filename: string }) => void;
|
|
const firstEnqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
|
resolveFirstEnqueue = resolve;
|
|
});
|
|
let enqueueCount = 0;
|
|
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
|
if (command === 'enqueue_download') {
|
|
enqueueCount += 1;
|
|
return (enqueueCount === 1
|
|
? firstEnqueue
|
|
: Promise.resolve({ id: 'edited', filename: 'new.bin' })) as never;
|
|
}
|
|
if (command === 'get_pending_order') return Promise.resolve(['edited']) as never;
|
|
return Promise.resolve(undefined) as never;
|
|
});
|
|
|
|
const start = useDownloadStore.getState().startQueue('MAIN');
|
|
await vi.waitFor(() => expect(enqueueCount).toBe(1));
|
|
const update = useDownloadStore.getState().applyProperties('edited', { fileName: 'new.bin' });
|
|
resolveFirstEnqueue({ id: 'edited', filename: 'old.bin' });
|
|
|
|
await expect(update).resolves.toBeUndefined();
|
|
await expect(start).resolves.toEqual([]);
|
|
expect(enqueueCount).toBe(2);
|
|
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
|
|
'get_pending_order',
|
|
{ queueId: 'MAIN' }
|
|
);
|
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
|
fileName: 'new.bin',
|
|
hasBeenDispatched: true,
|
|
});
|
|
});
|
|
|
|
it('settles an in-flight dispatch before pausing so it cannot start after pause succeeds', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'paused', url: 'http://test', fileName: 'paused.bin', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
|
] as any[],
|
|
});
|
|
|
|
let resolveEnqueue!: (value: { id: string; filename: string }) => void;
|
|
const enqueue = new Promise<{ id: string; filename: string }>(resolve => {
|
|
resolveEnqueue = resolve;
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
|
if (command === 'enqueue_download') return enqueue as never;
|
|
return Promise.resolve(undefined) as never;
|
|
});
|
|
|
|
const start = useDownloadStore.getState().startQueue('MAIN');
|
|
await vi.waitFor(() => {
|
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
|
'enqueue_download',
|
|
expect.objectContaining({ item: expect.objectContaining({ id: 'paused' }) })
|
|
);
|
|
});
|
|
const pause = useDownloadStore.getState().pauseDownload('paused');
|
|
resolveEnqueue({ id: 'paused', filename: 'paused.bin' });
|
|
|
|
await expect(pause).resolves.toBeUndefined();
|
|
await expect(start).resolves.toEqual([]);
|
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({ status: 'paused' });
|
|
expect(
|
|
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'remove_download')
|
|
).toHaveLength(1);
|
|
expect(
|
|
vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download')
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
|
] as any[],
|
|
backendRegisteredIds: new Set(['1']),
|
|
});
|
|
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'resume_download') return false; // Not resumable
|
|
if (cmd === 'get_pending_order') return ['1'];
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().resumeDownload('1');
|
|
|
|
// It should have called resume_download, then unregistered, then enqueue_download
|
|
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
|
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
|
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
|
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
|
|
});
|
|
|
|
|
|
it('adds to the selected queue without dispatching', async () => {
|
|
await useDownloadStore.getState().addDownload({
|
|
id: 'queue-1',
|
|
url: 'https://example.com/queue.bin',
|
|
fileName: 'queue.bin',
|
|
category: 'Other',
|
|
dateAdded: ''
|
|
}, { type: 'add-to-queue', queueId: 'queue-b' });
|
|
|
|
const item = useDownloadStore.getState().downloads[0];
|
|
expect(item.status).toBe('staged');
|
|
expect(item.queueId).toBe('queue-b');
|
|
expect(item.queuePosition).toBe(0);
|
|
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
|
});
|
|
|
|
it('starts immediately in the main queue', async () => {
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'get_pending_order') return ['start-1'];
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().addDownload({
|
|
id: 'start-1',
|
|
url: 'https://example.com/start.bin',
|
|
fileName: 'start.bin',
|
|
category: 'Other',
|
|
dateAdded: ''
|
|
}, { type: 'start-now' });
|
|
|
|
const item = useDownloadStore.getState().downloads[0];
|
|
expect(item.queueId).toBe('00000000-0000-0000-0000-000000000001');
|
|
expect(item.hasBeenDispatched).toBe(true);
|
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
|
'enqueue_download',
|
|
expect.objectContaining({
|
|
item: expect.objectContaining({ id: 'start-1' })
|
|
})
|
|
);
|
|
});
|
|
|
|
it('does not replace an explicit no-limit item speed with the global speed limit', async () => {
|
|
const defaultSettings = useSettingsStore.getState();
|
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
|
...defaultSettings,
|
|
globalSpeedLimit: '2M'
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'get_pending_order') return ['uncapped'];
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().addDownload({
|
|
id: 'uncapped',
|
|
url: 'https://example.com/uncapped.bin',
|
|
fileName: 'uncapped.bin',
|
|
category: 'Other',
|
|
dateAdded: '',
|
|
speedLimit: '0'
|
|
}, { type: 'start-now' });
|
|
|
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
|
'enqueue_download',
|
|
expect.objectContaining({
|
|
item: expect.objectContaining({
|
|
id: 'uncapped',
|
|
speed_limit: '0'
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it('uses the global speed limit only when an item has no explicit speed override', async () => {
|
|
const defaultSettings = useSettingsStore.getState();
|
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
|
...defaultSettings,
|
|
globalSpeedLimit: '2M'
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'get_pending_order') return ['inherits-global'];
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().addDownload({
|
|
id: 'inherits-global',
|
|
url: 'https://example.com/inherits-global.bin',
|
|
fileName: 'inherits-global.bin',
|
|
category: 'Other',
|
|
dateAdded: ''
|
|
}, { type: 'start-now' });
|
|
|
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
|
'enqueue_download',
|
|
expect.objectContaining({
|
|
item: expect.objectContaining({
|
|
id: 'inherits-global',
|
|
speed_limit: '2M'
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it('reports a rejected immediate start instead of claiming success', async () => {
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
|
if (command === 'enqueue_download') {
|
|
throw new Error('backend unavailable');
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const added = await useDownloadStore.getState().addDownload({
|
|
id: 'rejected-start',
|
|
url: 'https://example.com/rejected.bin',
|
|
fileName: 'rejected.bin',
|
|
category: 'Other',
|
|
dateAdded: ''
|
|
}, { type: 'start-now' });
|
|
|
|
expect(added).toBe(false);
|
|
expect(useDownloadStore.getState().downloads[0].status).toBe('failed');
|
|
expect(useDownloadStore.getState().downloads[0].lastError).toBe('backend unavailable');
|
|
});
|
|
|
|
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'db_get_all_queues') return [];
|
|
if (cmd === 'db_get_all_downloads') {
|
|
return [JSON.stringify({
|
|
id: 'startup-failed',
|
|
url: 'https://example.com/file.bin',
|
|
fileName: 'file.bin',
|
|
status: 'queued',
|
|
category: 'Other',
|
|
dateAdded: '',
|
|
queueId: '00000000-0000-0000-0000-000000000001',
|
|
hasBeenDispatched: true
|
|
})];
|
|
}
|
|
if (cmd === 'enqueue_many') {
|
|
return [{
|
|
id: 'startup-failed',
|
|
success: false,
|
|
error: 'aria2 addUri failed: connection refused'
|
|
}];
|
|
}
|
|
if (cmd === 'get_pending_order') return [];
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().initDB();
|
|
|
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
|
status: 'failed',
|
|
lastError: 'aria2 addUri failed: connection refused'
|
|
});
|
|
});
|
|
|
|
it('redownloads fallback media without requiring a format selector', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [{
|
|
id: 'media-fallback',
|
|
url: 'https://youtube.com/watch?v=test',
|
|
fileName: 'watch',
|
|
destination: '/tmp',
|
|
status: 'completed',
|
|
category: 'Other',
|
|
dateAdded: '',
|
|
isMedia: true
|
|
}] as any[]
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'get_pending_order') return [];
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().redownload('media-fallback');
|
|
|
|
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
|
'enqueue_download',
|
|
expect.objectContaining({
|
|
item: expect.objectContaining({
|
|
is_media: true,
|
|
format_selector: null
|
|
})
|
|
})
|
|
);
|
|
});
|
|
|
|
it('starts and pauses all items regardless of legacy missing queue ids', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'ready', url: 'http://ready', fileName: 'ready', status: 'ready', category: 'Other', dateAdded: '' },
|
|
{ id: 'active', url: 'http://active', fileName: 'active', status: 'processing', category: 'Other', dateAdded: '' },
|
|
] as any[],
|
|
});
|
|
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'get_pending_order') return ['ready'];
|
|
return undefined;
|
|
});
|
|
|
|
expect(await useDownloadStore.getState().startAll()).toBe(1);
|
|
expect(await useDownloadStore.getState().pauseAll()).toBe(2);
|
|
|
|
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
|
expect(calls.some(call => call[0] === 'enqueue_download')).toBe(true);
|
|
expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true);
|
|
});
|
|
|
|
it('migrates legacy downloads without queue ids into the main queue', async () => {
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'db_get_all_queues') return [];
|
|
if (cmd === 'db_get_all_downloads') {
|
|
return [JSON.stringify({
|
|
id: 'legacy',
|
|
url: 'https://example.com/legacy.bin',
|
|
fileName: 'legacy.bin',
|
|
status: 'ready',
|
|
category: 'Other',
|
|
dateAdded: ''
|
|
})];
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
await useDownloadStore.getState().initDB();
|
|
|
|
expect(useDownloadStore.getState().downloads[0].queueId)
|
|
.toBe('00000000-0000-0000-0000-000000000001');
|
|
});
|
|
|
|
it('pauses queued, downloading, processing, and retrying queue items', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: ['queued', 'downloading', 'processing', 'retrying'].map((status, index) => ({
|
|
id: `${index}`,
|
|
url: `https://example.com/${index}`,
|
|
fileName: `${index}.bin`,
|
|
status,
|
|
category: 'Other',
|
|
dateAdded: '',
|
|
queueId: 'queue-a'
|
|
})) as any[]
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
|
|
|
expect(await useDownloadStore.getState().pauseQueue('queue-a')).toBe(4);
|
|
expect(
|
|
vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'pause_download')
|
|
).toHaveLength(4);
|
|
});
|
|
|
|
it('assigns selected unfinished downloads to a queue without moving completed items', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'ready', status: 'ready', queueId: 'old' },
|
|
{ id: 'done', status: 'completed', queueId: 'old' }
|
|
] as any[]
|
|
});
|
|
|
|
await useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
|
|
|
|
expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new');
|
|
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
|
|
});
|
|
|
|
it('disables scheduler when its last selected queue is deleted', async () => {
|
|
const originalSettings = useSettingsStore.getState();
|
|
const setScheduler = vi.fn();
|
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
|
scheduler: {
|
|
enabled: true,
|
|
selectedQueueIds: ['queue-a']
|
|
},
|
|
setScheduler
|
|
} as any);
|
|
useDownloadStore.setState({
|
|
queues: [
|
|
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main Queue', isMain: true },
|
|
{ id: 'queue-a', name: 'Scheduled', isMain: false }
|
|
],
|
|
downloads: []
|
|
});
|
|
|
|
await useDownloadStore.getState().removeQueue('queue-a');
|
|
|
|
expect(setScheduler).toHaveBeenCalledWith(expect.objectContaining({
|
|
enabled: false,
|
|
selectedQueueIds: []
|
|
}));
|
|
vi.mocked(useSettingsStore.getState).mockReturnValue(originalSettings);
|
|
});
|
|
|
|
it('retains the UI item when backend removal fails', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'active', url: 'https://example.com/file', fileName: 'file', status: 'downloading', category: 'Other', dateAdded: '', queueId: 'main' }
|
|
] as any[]
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation((command: string) => {
|
|
if (command === 'remove_download') {
|
|
return Promise.reject(new Error('writer did not stop')) as never;
|
|
}
|
|
return Promise.resolve(undefined) as never;
|
|
});
|
|
|
|
await expect(useDownloadStore.getState().removeDownload('active', true))
|
|
.rejects.toThrow('writer did not stop');
|
|
expect(useDownloadStore.getState().downloads.map(download => download.id))
|
|
.toEqual(['active']);
|
|
});
|
|
|
|
it('starts staged queue items in their persisted queue order', async () => {
|
|
useDownloadStore.setState({
|
|
downloads: [
|
|
{ id: 'later', url: 'https://example.com/later', fileName: 'later', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 1 },
|
|
{ id: 'first', url: 'https://example.com/first', fileName: 'first', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 0 }
|
|
] as any[]
|
|
});
|
|
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
|
|
if (command === 'get_pending_order') {
|
|
return [(args as { queueId: string }).queueId === 'queue-a' ? 'first' : 'later'];
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
expect(await useDownloadStore.getState().startQueue('queue-a')).toEqual(['first', 'later']);
|
|
const enqueuedIds = vi.mocked(ipc.invokeCommand).mock.calls
|
|
.filter(call => call[0] === 'enqueue_download')
|
|
.map(call => (call[1] as any).item.id);
|
|
expect(enqueuedIds).toEqual(['first', 'later']);
|
|
expect((vi.mocked(ipc.invokeCommand).mock.calls.find(call =>
|
|
call[0] === 'enqueue_download'
|
|
)?.[1] as any).item.queue_id).toBe('queue-a');
|
|
});
|
|
|
|
it('preserves extension request headers and cookies for the Add modal', async () => {
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://example.com/file.bin'],
|
|
referer: 'https://example.com/page',
|
|
silent: false,
|
|
filename: 'file.bin',
|
|
headers: 'X-Test: value',
|
|
cookies: 'session=secret',
|
|
media: false
|
|
});
|
|
|
|
const state = useDownloadStore.getState();
|
|
expect(state.isAddModalOpen).toBe(true);
|
|
expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
|
|
expect(state.pendingAddReferer).toBe('https://example.com/page');
|
|
expect(state.pendingAddFilename).toBe('file.bin');
|
|
expect(state.pendingAddHeaders).toBe('X-Test: value');
|
|
expect(state.pendingAddCookies).toBe('session=secret');
|
|
expect(state.pendingAddMediaUrls).toEqual([]);
|
|
});
|
|
|
|
it('does not reuse stale extension metadata for a later single-link handoff', async () => {
|
|
useDownloadStore.setState({
|
|
isAddModalOpen: true,
|
|
pendingAddUrls: '',
|
|
pendingAddReferer: 'https://old.example/page',
|
|
pendingAddFilename: '7aae36e6-00ec-4e7d-8dec-f14ace170bdb',
|
|
pendingAddHeaders: 'X-Old: value',
|
|
pendingAddCookies: 'old=session',
|
|
pendingAddMediaUrls: []
|
|
});
|
|
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://github.com/center2055/OnionHop/releases/download/v3.5/OnionHop-3.5-macOS-arm64.dmg'],
|
|
referer: 'https://github.com/center2055/OnionHop/releases/tag/v3.5',
|
|
silent: false,
|
|
filename: null,
|
|
headers: 'User-Agent: Firefox Test',
|
|
cookies: null,
|
|
media: false
|
|
});
|
|
|
|
const state = useDownloadStore.getState();
|
|
expect(state.pendingAddUrls).toBe('https://github.com/center2055/OnionHop/releases/download/v3.5/OnionHop-3.5-macOS-arm64.dmg');
|
|
expect(state.pendingAddReferer).toBe('https://github.com/center2055/OnionHop/releases/tag/v3.5');
|
|
expect(state.pendingAddFilename).toBe('');
|
|
expect(state.pendingAddHeaders).toBe('User-Agent: Firefox Test');
|
|
expect(state.pendingAddCookies).toBe('');
|
|
expect(state.pendingAddMediaUrls).toEqual([]);
|
|
});
|
|
|
|
it('routes silent extension captures to the Add Modal instead of queuing immediately', async () => {
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://example.com/downloads/report.pdf'],
|
|
referer: 'https://example.com/page',
|
|
silent: true,
|
|
filename: 'report.pdf',
|
|
headers: 'User-Agent: Test',
|
|
cookies: 'session=secret',
|
|
media: false
|
|
});
|
|
|
|
const state = useDownloadStore.getState();
|
|
expect(state.isAddModalOpen).toBe(true);
|
|
expect(state.pendingAddUrls).toBe('https://example.com/downloads/report.pdf');
|
|
expect(state.pendingAddReferer).toBe('https://example.com/page');
|
|
expect(state.pendingAddFilename).toBe('report.pdf');
|
|
expect(state.pendingAddHeaders).toBe('User-Agent: Test');
|
|
expect(state.pendingAddCookies).toBe('session=secret');
|
|
expect(state.pendingAddMediaUrls).toEqual([]);
|
|
});
|
|
|
|
it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => {
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://first.example/file.zip'],
|
|
referer: 'https://first.example/page',
|
|
silent: true,
|
|
filename: 'first.zip',
|
|
headers: 'User-Agent: First Browser',
|
|
cookies: 'first=session',
|
|
media: false
|
|
});
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://second.example/file.zip'],
|
|
referer: 'https://second.example/page',
|
|
silent: true,
|
|
filename: 'second.zip',
|
|
headers: 'User-Agent: Second Browser',
|
|
cookies: 'second=session',
|
|
media: false
|
|
});
|
|
|
|
const state = useDownloadStore.getState();
|
|
expect(state.pendingAddUrls).toBe(
|
|
'https://first.example/file.zip\nhttps://second.example/file.zip'
|
|
);
|
|
expect(state.pendingAddRequestVersion).toBe(2);
|
|
expect(state.pendingAddLatestUrls).toBe('https://second.example/file.zip');
|
|
expect(state.pendingAddRequestContexts).toEqual({
|
|
'https://first.example/file.zip': {
|
|
version: 1,
|
|
referer: 'https://first.example/page',
|
|
filename: 'first.zip',
|
|
headers: 'User-Agent: First Browser',
|
|
cookies: 'first=session'
|
|
},
|
|
'https://second.example/file.zip': {
|
|
version: 2,
|
|
referer: 'https://second.example/page',
|
|
filename: 'second.zip',
|
|
headers: 'User-Agent: Second Browser',
|
|
cookies: 'second=session'
|
|
}
|
|
});
|
|
});
|
|
|
|
it('preserves explicit extension media intent for non-allow-listed pages', async () => {
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://adult.example/watch/123'],
|
|
referer: 'https://adult.example/watch/123',
|
|
silent: false,
|
|
filename: null,
|
|
headers: 'User-Agent: Firefox Test',
|
|
cookies: 'session=secret',
|
|
media: true
|
|
});
|
|
|
|
const state = useDownloadStore.getState();
|
|
expect(state.isAddModalOpen).toBe(true);
|
|
expect(state.pendingAddUrls).toBe('https://adult.example/watch/123');
|
|
expect(state.pendingAddMediaUrls).toEqual(['https://adult.example/watch/123']);
|
|
expect(state.pendingAddCookies).toBe('');
|
|
});
|
|
|
|
it('deduplicates forced media URLs and drops stale media intent when opening fresh', async () => {
|
|
useDownloadStore.setState({
|
|
isAddModalOpen: true,
|
|
pendingAddUrls: 'https://adult.example/watch/123',
|
|
pendingAddMediaUrls: ['https://adult.example/watch/123']
|
|
});
|
|
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://adult.example/watch/123'],
|
|
referer: 'https://adult.example/watch/123',
|
|
silent: false,
|
|
filename: null,
|
|
headers: 'User-Agent: Firefox Test',
|
|
cookies: 'session=secret',
|
|
media: true
|
|
});
|
|
|
|
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([
|
|
'https://adult.example/watch/123'
|
|
]);
|
|
|
|
useDownloadStore.setState({
|
|
isAddModalOpen: false,
|
|
pendingAddMediaUrls: ['https://stale.example/watch']
|
|
});
|
|
|
|
await useDownloadStore.getState().handleExtensionDownload({
|
|
urls: ['https://example.com/file.bin'],
|
|
referer: 'https://example.com/page',
|
|
silent: false,
|
|
filename: 'file.bin',
|
|
headers: 'User-Agent: Firefox Test',
|
|
cookies: null,
|
|
media: false
|
|
});
|
|
|
|
expect(useDownloadStore.getState().pendingAddMediaUrls).toEqual([]);
|
|
});
|
|
});
|