492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
import { describe, it, expect, beforeAll, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
|
|
|
type StorageEntry = { value: unknown };
|
|
|
|
const notificationMock = {
|
|
info: mock(() => {}),
|
|
success: mock(() => {}),
|
|
warning: mock(() => {}),
|
|
error: mock(() => {}),
|
|
notify: mock(() => {}),
|
|
};
|
|
|
|
const runtimeConfig = {
|
|
app: {
|
|
baseURL: '/base-path',
|
|
},
|
|
};
|
|
|
|
mock.module('#imports', () => ({
|
|
useRuntimeConfig: () => runtimeConfig,
|
|
useNotification: () => notificationMock,
|
|
}));
|
|
|
|
globalThis.useRuntimeConfig = () => runtimeConfig as any;
|
|
globalThis.useNotification = () => notificationMock as any;
|
|
|
|
const storageMap = new Map<string, StorageEntry | unknown>();
|
|
|
|
const useStorageFn = mock(<T>(key: string, defaultValue: T) => {
|
|
if (!storageMap.has(key)) {
|
|
storageMap.set(key, { value: defaultValue });
|
|
}
|
|
return storageMap.get(key);
|
|
});
|
|
|
|
mock.module('@vueuse/core', () => ({
|
|
useStorage: useStorageFn,
|
|
}));
|
|
|
|
const clipboardWriteMock = mock(() => Promise.resolve());
|
|
const fetchMock = mock(() => Promise.resolve({ status: 200 } as Response));
|
|
const getRandomValuesMock = mock((buffer: Uint8Array) => {
|
|
buffer.fill(1);
|
|
return buffer;
|
|
});
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalClipboard = globalThis.navigator?.clipboard;
|
|
const originalCrypto = globalThis.crypto;
|
|
|
|
let utils: Awaited<typeof import('~/utils/index')>;
|
|
let fetchSpy: ReturnType<typeof spyOn> | undefined;
|
|
|
|
const resetStorage = () => {
|
|
storageMap.clear();
|
|
storageMap.set('random_bg', { value: true });
|
|
storageMap.set('random_bg_opacity', { value: 0.95 });
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
utils = await import('~/utils/index');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetStorage();
|
|
runtimeConfig.app.baseURL = '/base-path';
|
|
notificationMock.info.mockClear();
|
|
notificationMock.success.mockClear();
|
|
notificationMock.warning.mockClear();
|
|
notificationMock.error.mockClear();
|
|
notificationMock.notify.mockClear();
|
|
useStorageFn.mockClear();
|
|
|
|
fetchMock.mockClear();
|
|
clipboardWriteMock.mockClear();
|
|
getRandomValuesMock.mockClear();
|
|
|
|
if (typeof originalFetch === 'function') {
|
|
fetchSpy = spyOn(globalThis, 'fetch');
|
|
fetchSpy.mockImplementation(fetchMock as any);
|
|
} else {
|
|
(globalThis as any).fetch = fetchMock;
|
|
fetchSpy = undefined;
|
|
}
|
|
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText: clipboardWriteMock },
|
|
});
|
|
|
|
Object.defineProperty(globalThis, 'crypto', {
|
|
configurable: true,
|
|
value: { getRandomValues: getRandomValuesMock },
|
|
});
|
|
Object.defineProperty(window as any, 'crypto', {
|
|
configurable: true,
|
|
value: { getRandomValues: getRandomValuesMock },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fetchSpy) {
|
|
fetchSpy.mockRestore();
|
|
} else if (!originalFetch) {
|
|
delete (globalThis as any).fetch;
|
|
} else {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
if (originalClipboard) {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: originalClipboard,
|
|
});
|
|
} else {
|
|
delete (navigator as any).clipboard;
|
|
}
|
|
|
|
Object.defineProperty(globalThis, 'crypto', {
|
|
configurable: true,
|
|
value: originalCrypto,
|
|
});
|
|
Object.defineProperty(window as any, 'crypto', {
|
|
configurable: true,
|
|
value: originalCrypto,
|
|
});
|
|
|
|
if (document.body) {
|
|
document.body.innerHTML = '';
|
|
document.body.removeAttribute('style');
|
|
}
|
|
});
|
|
|
|
describe('object access helpers', () => {
|
|
it('ag_nested_value', () => {
|
|
const payload = { a: { b: { c: 42 } } };
|
|
expect(utils.ag(payload, 'a.b.c')).toBe(42);
|
|
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback');
|
|
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default');
|
|
});
|
|
|
|
it('ag_set_path', () => {
|
|
const payload: Record<string, unknown> = {};
|
|
utils.ag_set(payload, 'a.b.c', 99);
|
|
expect(payload).toEqual({ a: { b: { c: 99 } } });
|
|
});
|
|
|
|
it('clean_object_keys', () => {
|
|
const source = { id: 1, keep: true, drop: false };
|
|
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true });
|
|
expect(utils.cleanObject(source, [])).toEqual(source);
|
|
});
|
|
|
|
it('strip_path_base', () => {
|
|
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4');
|
|
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt');
|
|
});
|
|
});
|
|
|
|
describe('string manipulation helpers', () => {
|
|
it('replace_tokens', () => {
|
|
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } });
|
|
expect(result).toBe('Hello YTPTube!');
|
|
});
|
|
|
|
it('itrim_edges', () => {
|
|
expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
|
|
expect(utils.iTrim('::value', ':', 'start')).toBe('value');
|
|
expect(utils.iTrim('value::', ':', 'end')).toBe('value');
|
|
});
|
|
|
|
it('itrim_slash', () => {
|
|
expect(utils.iTrim('//value//', '/', 'both')).toBe('value');
|
|
expect(utils.iTrim('/value', '/', 'start')).toBe('value');
|
|
expect(utils.iTrim('value/', '/', 'end')).toBe('value');
|
|
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple');
|
|
});
|
|
|
|
it('itrim_backslash', () => {
|
|
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value');
|
|
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value');
|
|
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value');
|
|
});
|
|
|
|
it('itrim_hyphen', () => {
|
|
expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
|
|
expect(utils.iTrim('-value', '-', 'start')).toBe('value');
|
|
expect(utils.iTrim('value-', '-', 'end')).toBe('value');
|
|
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple');
|
|
});
|
|
|
|
it('itrim_caret', () => {
|
|
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value');
|
|
expect(utils.iTrim('^value', '^', 'start')).toBe('value');
|
|
expect(utils.iTrim('value^', '^', 'end')).toBe('value');
|
|
});
|
|
|
|
it('itrim_brackets', () => {
|
|
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]');
|
|
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[');
|
|
});
|
|
|
|
it('itrim_dot', () => {
|
|
expect(utils.iTrim('..value..', '.', 'both')).toBe('value');
|
|
expect(utils.iTrim('.value', '.', 'start')).toBe('value');
|
|
expect(utils.iTrim('value.', '.', 'end')).toBe('value');
|
|
});
|
|
|
|
it('itrim_regex_chars', () => {
|
|
expect(utils.iTrim('**value**', '*', 'both')).toBe('value');
|
|
expect(utils.iTrim('++value++', '+', 'both')).toBe('value');
|
|
expect(utils.iTrim('??value??', '?', 'both')).toBe('value');
|
|
expect(utils.iTrim('||value||', '|', 'both')).toBe('value');
|
|
expect(utils.iTrim('((value))', '(', 'both')).toBe('value))');
|
|
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value');
|
|
});
|
|
|
|
it('itrim_empty', () => {
|
|
expect(utils.iTrim('', '/', 'both')).toBe('');
|
|
});
|
|
|
|
it('itrim_empty_delimiter', () => {
|
|
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required');
|
|
});
|
|
|
|
it('itrim_preserve_middle', () => {
|
|
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file');
|
|
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file');
|
|
});
|
|
|
|
it('encode_path', () => {
|
|
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4');
|
|
});
|
|
|
|
it('encode_percent', () => {
|
|
expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe(
|
|
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
|
);
|
|
});
|
|
|
|
it('encode_specials', () => {
|
|
expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe(
|
|
'100%25%20complete%20%5BHD%5D%20%231.mp4',
|
|
);
|
|
});
|
|
|
|
it('keep_segments', () => {
|
|
expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe(
|
|
'folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
|
);
|
|
});
|
|
|
|
it('preserve_encoded', () => {
|
|
expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe(
|
|
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
|
);
|
|
});
|
|
|
|
it('mix_encoded_input', () => {
|
|
expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4');
|
|
});
|
|
|
|
it('encode_query_chars', () => {
|
|
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe(
|
|
'query%3Fparam%3Dvalue%26key%3D100%25.mp4',
|
|
);
|
|
});
|
|
|
|
it('encode_empty', () => {
|
|
expect(utils.encodePath('')).toBe('');
|
|
});
|
|
|
|
it('encode_simple_filename', () => {
|
|
expect(utils.encodePath('video.mp4')).toBe('video.mp4');
|
|
});
|
|
|
|
it('encode_unicode', () => {
|
|
expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4');
|
|
});
|
|
|
|
it('encode_parentheses', () => {
|
|
expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4');
|
|
});
|
|
|
|
it('strip_ansi', () => {
|
|
const sample = '\u001b[31mError\u001b[0m';
|
|
expect(utils.removeANSIColors(sample)).toBe('Error');
|
|
});
|
|
|
|
it('basename_trim_ext', () => {
|
|
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
|
|
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video');
|
|
expect(utils.basename('', '.mp4')).toBe('');
|
|
});
|
|
|
|
it('dirname_parent', () => {
|
|
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads');
|
|
expect(utils.dirname('video.mp4')).toBe('.');
|
|
expect(utils.dirname('/file')).toBe('/');
|
|
});
|
|
|
|
it('format_bytes', () => {
|
|
expect(utils.formatBytes(0)).toBe('0 Bytes');
|
|
expect(utils.formatBytes(1024)).toBe('1 KiB');
|
|
});
|
|
|
|
it('format_time', () => {
|
|
expect(utils.formatTime(59)).toBe('59');
|
|
expect(utils.formatTime(90)).toBe('01:30');
|
|
expect(utils.formatTime(3661)).toBe('01:01:01');
|
|
});
|
|
});
|
|
|
|
describe('data conversion helpers', () => {
|
|
it('has_data', () => {
|
|
expect(utils.has_data({ key: 'value' })).toBe(true);
|
|
expect(utils.has_data('""')).toBe(false);
|
|
expect(utils.has_data('[1,2]')).toBe(true);
|
|
expect(utils.has_data('')).toBe(false);
|
|
});
|
|
|
|
it('encode_decode_roundtrip', () => {
|
|
const payload = { name: 'YTPTube', count: 2 };
|
|
const encoded = utils.encode(payload);
|
|
expect(typeof encoded).toBe('string');
|
|
expect(utils.decode(encoded)).toEqual(payload);
|
|
});
|
|
|
|
it('parse_query_params', () => {
|
|
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
|
|
});
|
|
|
|
it('prefix_runtime_base', () => {
|
|
runtimeConfig.app.baseURL = '/base-path';
|
|
expect(utils.uri('/api/test')).toBe('/base-path/api/test');
|
|
runtimeConfig.app.baseURL = '/';
|
|
expect(utils.uri('/api/test')).toBe('/api/test');
|
|
});
|
|
|
|
it('build_file_url', () => {
|
|
runtimeConfig.app.baseURL = '/base-path';
|
|
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' });
|
|
expect(url).toBe('/base-path/api/download/music/song.mp3');
|
|
});
|
|
|
|
it('build_m3u8_url', () => {
|
|
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8');
|
|
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8');
|
|
});
|
|
|
|
it('detect_download_skipped', () => {
|
|
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: true } as any)).toBe(true);
|
|
});
|
|
|
|
it('ignore_non_skipped', () => {
|
|
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: false } as any)).toBe(false);
|
|
expect(utils.isDownloadSkipped({ status: 'downloading', download_skipped: true } as any)).toBe(false);
|
|
expect(utils.isDownloadSkipped(undefined as any)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('dom and browser helpers', () => {
|
|
it('write_clipboard', async () => {
|
|
utils.copyText('sample');
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(clipboardWriteMock).toHaveBeenCalledWith('sample');
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.');
|
|
});
|
|
|
|
it('lock_opacity', () => {
|
|
const result = utils.disableOpacity();
|
|
expect(result).toBe(true);
|
|
expect(document.body.style.opacity).toBe('1');
|
|
});
|
|
|
|
it('skip_disabled_bg', () => {
|
|
document.body.removeAttribute('style');
|
|
storageMap.clear();
|
|
const originalOpacity = document.body.style.opacity;
|
|
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
|
if (key === 'random_bg') {
|
|
return null;
|
|
}
|
|
if (!storageMap.has(key)) {
|
|
storageMap.set(key, { value: defaultValue });
|
|
}
|
|
return storageMap.get(key);
|
|
});
|
|
|
|
const result = utils.disableOpacity();
|
|
expect(result).toBe(false);
|
|
expect(document.body.getAttribute('style')).toBeNull();
|
|
expect(document.body.style.opacity || '').toBe(originalOpacity || '');
|
|
});
|
|
|
|
it('restore_opacity', () => {
|
|
utils.disableOpacity();
|
|
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
|
if (!storageMap.has(key)) {
|
|
storageMap.set(key, { value: defaultValue });
|
|
}
|
|
return storageMap.get(key);
|
|
});
|
|
|
|
storageMap.set('random_bg_opacity', { value: 0.75 });
|
|
const result = utils.enableOpacity();
|
|
expect(result).toBe(true);
|
|
expect(document.body.style.opacity).toBe('0.75');
|
|
});
|
|
|
|
it('keep_opacity_lock', () => {
|
|
utils.disableOpacity();
|
|
storageMap.set('random_bg_opacity', { value: 0.35 });
|
|
|
|
const result = utils.syncOpacity();
|
|
|
|
expect(result).toBe(true);
|
|
expect(document.body.style.opacity).toBe('1');
|
|
});
|
|
|
|
it('clear_disabled_bg', () => {
|
|
document.body.style.opacity = '0.8';
|
|
storageMap.set('random_bg', { value: false });
|
|
|
|
const result = utils.syncOpacity();
|
|
|
|
expect(result).toBe(true);
|
|
expect(document.body.style.opacity).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('network and id helpers', () => {
|
|
it('prefix_base_url', async () => {
|
|
const responseMock = { status: 200 } as Response;
|
|
fetchMock.mockResolvedValue(responseMock);
|
|
|
|
const response = await utils.request('/api/test');
|
|
|
|
expect(response).toBe(responseMock);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, options] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe('/base-path/api/test');
|
|
expect(options?.method).toBe('GET');
|
|
expect(options?.credentials).toBe('same-origin');
|
|
expect((options?.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
|
expect((options?.headers as Record<string, string>)['Accept']).toBe('application/json');
|
|
expect((options as Record<string, unknown>).withCredentials).toBe(true);
|
|
});
|
|
|
|
it('post_cli_args', async () => {
|
|
const jsonMock = mock().mockResolvedValue({ success: true });
|
|
const responseMock = { status: 200, json: jsonMock };
|
|
fetchMock.mockResolvedValue(responseMock);
|
|
|
|
const result = await utils.convertCliOptions('--help');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, options] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe('/base-path/api/yt-dlp/convert');
|
|
expect(options?.method).toBe('POST');
|
|
expect(options?.body).toBe(JSON.stringify({ args: '--help' }));
|
|
expect(jsonMock).toHaveBeenCalled();
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it('throw_cli_error', async () => {
|
|
const jsonMock = mock().mockResolvedValue({ error: 'fail' });
|
|
const responseMock = { status: 400, json: jsonMock };
|
|
fetchMock.mockResolvedValue(responseMock);
|
|
|
|
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail');
|
|
});
|
|
|
|
});
|
|
|
|
describe('async helpers', () => {
|
|
it('resolve_truthy', async () => {
|
|
const values = [false, false, 'done'];
|
|
const result = await utils.awaiter(() => values.shift(), 500, 0.01);
|
|
expect(result).toBe('done');
|
|
});
|
|
|
|
it('timeout', async () => {
|
|
const result = await utils.awaiter(() => false, 50, 0.01);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|