update ui tests
This commit is contained in:
parent
ba2bfd6c9b
commit
387a071a59
5 changed files with 1236 additions and 321 deletions
|
|
@ -23,21 +23,21 @@
|
|||
"@vueuse/nuxt": "^13.9.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cron-parser": "^5.3.1",
|
||||
"cron-parser": "^5.4.0",
|
||||
"cronstrue": "^3.3.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.12",
|
||||
"marked": "^16.3.0",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.7",
|
||||
"marked-gfm-heading-id": "^4.1.2",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-toastification": "2.0.0-rc.5",
|
||||
"marked": "^16.2.1",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.7",
|
||||
"marked-gfm-heading-id": "^4.1.2"
|
||||
"vue-toastification": "2.0.0-rc.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.44.0",
|
||||
"eslint": "^9.35.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
550
ui/tests/utils/index.test.ts
Normal file
550
ui/tests/utils/index.test.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { MockInstance } from 'vitest'
|
||||
|
||||
type StorageEntry = { value: unknown }
|
||||
|
||||
const notificationMock = {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}
|
||||
|
||||
const runtimeConfig = {
|
||||
app: {
|
||||
baseURL: '/base-path',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('#imports', () => ({
|
||||
useRuntimeConfig: vi.fn(() => runtimeConfig),
|
||||
useNotification: vi.fn(() => notificationMock),
|
||||
}))
|
||||
|
||||
// Mock the global Nuxt composables since they auto-import
|
||||
vi.stubGlobal('useRuntimeConfig', vi.fn(() => runtimeConfig))
|
||||
vi.stubGlobal('useNotification', vi.fn(() => notificationMock))
|
||||
|
||||
const storageMap = new Map<string, StorageEntry | unknown>()
|
||||
|
||||
const useStorageFn = vi.fn(<T>(key: string, defaultValue: T) => {
|
||||
if (!storageMap.has(key)) {
|
||||
storageMap.set(key, { value: defaultValue })
|
||||
}
|
||||
return storageMap.get(key)
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useStorage: useStorageFn,
|
||||
}))
|
||||
|
||||
const clipboardWriteMock = vi.fn(() => Promise.resolve())
|
||||
const fetchMock = vi.fn()
|
||||
const getRandomValuesMock = vi.fn((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('../../app/utils/index')>
|
||||
let fetchSpy: MockInstance | undefined
|
||||
|
||||
const resetStorage = () => {
|
||||
storageMap.clear()
|
||||
storageMap.set('random_bg', { value: true })
|
||||
storageMap.set('random_bg_opacity', { value: 0.95 })
|
||||
}
|
||||
|
||||
// Minimal DOM/window/custom event shims
|
||||
type Listener = (evt: { type: string; detail?: any }) => void
|
||||
const listeners = new Map<string, Listener[]>()
|
||||
|
||||
const win: any = {
|
||||
addEventListener: (type: string, cb: Listener) => {
|
||||
const arr = listeners.get(type) ?? []
|
||||
arr.push(cb)
|
||||
listeners.set(type, arr)
|
||||
},
|
||||
removeEventListener: (type: string, cb: Listener) => {
|
||||
const arr = listeners.get(type) ?? []
|
||||
listeners.set(type, arr.filter(x => x !== cb))
|
||||
},
|
||||
dispatchEvent: (evt: { type: string; detail?: any }) => {
|
||||
const arr = listeners.get(evt.type) ?? []
|
||||
arr.forEach(cb => cb(evt))
|
||||
return true
|
||||
},
|
||||
focus: () => {},
|
||||
navigator: {},
|
||||
}
|
||||
globalThis.window = win as unknown as Window & typeof globalThis
|
||||
// Ensure global navigator is present
|
||||
globalThis.navigator = win.navigator as Navigator
|
||||
|
||||
class MiniCustomEvent<T = any> {
|
||||
type: string
|
||||
detail?: T
|
||||
constructor(type: string, init?: { detail?: T }) {
|
||||
this.type = type
|
||||
this.detail = init?.detail
|
||||
}
|
||||
}
|
||||
globalThis.CustomEvent = MiniCustomEvent as unknown as typeof CustomEvent
|
||||
|
||||
class ClassList {
|
||||
private set = new Set<string>()
|
||||
contains = (c: string) => this.set.has(c)
|
||||
add = (c: string) => { this.set.add(c) }
|
||||
remove = (c: string) => { this.set.delete(c) }
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
id = ''
|
||||
classList = new ClassList()
|
||||
private attrs = new Map<string, string>()
|
||||
innerHTML = ''
|
||||
setAttribute(k: string, v: string) { this.attrs.set(k, v) }
|
||||
getAttribute(k: string) { return this.attrs.has(k) ? (this.attrs.get(k) as string) : null }
|
||||
removeAttribute(k: string) { this.attrs.delete(k) }
|
||||
}
|
||||
|
||||
const registry = new Map<string, FakeElement>()
|
||||
const body = new FakeElement()
|
||||
;(body as any).appendChild = (el: FakeElement) => {
|
||||
if (el.id) registry.set(el.id, el)
|
||||
}
|
||||
|
||||
const doc: any = {
|
||||
body,
|
||||
createElement: (_tag: string) => new FakeElement(),
|
||||
querySelector: (sel: string) => {
|
||||
if (sel === 'body') return body
|
||||
if (sel.startsWith('#')) return registry.get(sel.slice(1)) ?? null
|
||||
return null
|
||||
},
|
||||
execCommand: () => true,
|
||||
}
|
||||
globalThis.document = doc as unknown as Document
|
||||
globalThis.HTMLElement = FakeElement as unknown as typeof HTMLElement
|
||||
globalThis.Node = FakeElement as unknown as typeof Node
|
||||
// btoa/atob polyfills
|
||||
globalThis.btoa = globalThis.btoa ?? ((str: string) => Buffer.from(str, 'binary').toString('base64'))
|
||||
globalThis.atob = globalThis.atob ?? ((b64: string) => Buffer.from(b64, 'base64').toString('binary'))
|
||||
|
||||
beforeAll(async () => {
|
||||
// Import utils after all mocks are set up
|
||||
utils = await import('../../app/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.mockReset()
|
||||
clipboardWriteMock.mockReset()
|
||||
getRandomValuesMock.mockClear()
|
||||
|
||||
if (typeof originalFetch === 'function') {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(fetchMock)
|
||||
} 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')
|
||||
}
|
||||
})
|
||||
|
||||
// no afterAll needed for lightweight stubs
|
||||
|
||||
describe('utils/index setup', () => {
|
||||
it('exposes core utilities after mocks initialize', () => {
|
||||
expect(Array.isArray(utils.separators)).toBe(true)
|
||||
expect(typeof utils.getValue).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('object access helpers', () => {
|
||||
it('getValue resolves direct values and callables', () => {
|
||||
expect(utils.getValue(5)).toBe(5)
|
||||
expect(utils.getValue(() => 7)).toBe(7)
|
||||
})
|
||||
|
||||
it('ag returns nested value or default 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 sets nested path creating objects as needed', () => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
utils.ag_set(payload, 'a.b.c', 99)
|
||||
expect(payload).toEqual({ a: { b: { c: 99 } } })
|
||||
})
|
||||
|
||||
it('cleanObject removes requested 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('stripPath removes base prefix and leading slashes', () => {
|
||||
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('r replaces tokens with context values', () => {
|
||||
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } })
|
||||
expect(result).toBe('Hello YTPTube!')
|
||||
})
|
||||
|
||||
it('iTrim trims delimiters at requested positions', () => {
|
||||
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
|
||||
expect(utils.iTrim('::value', ':', 'start')).toBe('value')
|
||||
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
|
||||
})
|
||||
|
||||
it('eTrim and sTrim delegate to iTrim ends', () => {
|
||||
expect(utils.eTrim('##name##', '#')).toBe('##name')
|
||||
expect(utils.sTrim('##name##', '#')).toBe('name##')
|
||||
})
|
||||
|
||||
it('ucFirst capitalizes first character', () => {
|
||||
expect(utils.ucFirst('ytp')).toBe('Ytp')
|
||||
expect(utils.ucFirst('')).toBe('')
|
||||
})
|
||||
|
||||
it('encodePath safely encodes components', () => {
|
||||
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4')
|
||||
})
|
||||
|
||||
it('removeANSIColors strips escape codes', () => {
|
||||
const sample = '\u001b[31mError\u001b[0m'
|
||||
expect(utils.removeANSIColors(sample)).toBe('Error')
|
||||
})
|
||||
|
||||
it('dec2hex converts to two character hex strings', () => {
|
||||
expect(utils.dec2hex(15)).toBe('0f')
|
||||
expect(utils.dec2hex(255)).toBe('ff')
|
||||
})
|
||||
|
||||
it('basename returns final segment optionally trimming extension', () => {
|
||||
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 returns parent directory', () => {
|
||||
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads')
|
||||
expect(utils.dirname('video.mp4')).toBe('.')
|
||||
expect(utils.dirname('/file')).toBe('/')
|
||||
})
|
||||
|
||||
it('formatBytes returns human readable strings', () => {
|
||||
expect(utils.formatBytes(0)).toBe('0 Bytes')
|
||||
expect(utils.formatBytes(1024)).toBe('1 KiB')
|
||||
})
|
||||
|
||||
it('formatTime renders hh:mm:ss or mm:ss', () => {
|
||||
expect(utils.formatTime(59)).toBe('59')
|
||||
expect(utils.formatTime(90)).toBe('01:30')
|
||||
expect(utils.formatTime(3661)).toBe('01:01:01')
|
||||
})
|
||||
|
||||
it('getSeparatorsName returns human readable label', () => {
|
||||
expect(utils.getSeparatorsName(',')).toContain('Comma')
|
||||
expect(utils.getSeparatorsName('*')).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('data conversion helpers', () => {
|
||||
it('has_data detects arrays, objects, and json strings', () => {
|
||||
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 and decode provide reversible transformation', () => {
|
||||
const payload = { name: 'YTPTube', count: 2 }
|
||||
const encoded = utils.encode(payload)
|
||||
expect(typeof encoded).toBe('string')
|
||||
expect(utils.decode(encoded)).toEqual(payload)
|
||||
})
|
||||
|
||||
it('makePagination builds a ranged pagination list', () => {
|
||||
const pages = utils.makePagination(5, 10, 1)
|
||||
const selected = pages.find((page: any) => page.selected)
|
||||
expect(selected?.page).toBe(5)
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
expect(pages[0]?.page).toBe(1)
|
||||
expect(pages[pages.length - 1]?.page).toBe(10)
|
||||
})
|
||||
|
||||
it('getQueryParams parses query strings', () => {
|
||||
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' })
|
||||
})
|
||||
|
||||
it('uri prefixes runtime base path', () => {
|
||||
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('makeDownload builds expected url with folder and filename', () => {
|
||||
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('makeDownload handles m3u8 base path', () => {
|
||||
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8')
|
||||
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dom and browser helpers', () => {
|
||||
it('awaitElement waits until element appears', async () => {
|
||||
vi.useFakeTimers()
|
||||
const callback = vi.fn()
|
||||
utils.awaitElement('#dynamic', callback)
|
||||
|
||||
setTimeout(() => {
|
||||
const el = document.createElement('div')
|
||||
el.id = 'dynamic'
|
||||
document.body.appendChild(el)
|
||||
}, 50)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
const callArgs = callback.mock.calls[0]
|
||||
expect(callArgs).toBeDefined()
|
||||
expect(Array.isArray(callArgs)).toBe(true)
|
||||
const [element, selector] = callArgs!
|
||||
expect((element as Element).id).toBe('dynamic')
|
||||
expect(selector).toBe('#dynamic')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('dEvent dispatches custom event with detail payload', () => {
|
||||
const detail = { foo: 'bar' }
|
||||
let received: unknown = null
|
||||
const listener = (event: Event) => {
|
||||
received = (event as CustomEvent).detail
|
||||
}
|
||||
|
||||
window.addEventListener('custom-detail', listener)
|
||||
const dispatched = utils.dEvent('custom-detail', detail)
|
||||
window.removeEventListener('custom-detail', listener)
|
||||
|
||||
expect(dispatched).toBe(true)
|
||||
expect(received).toEqual(detail)
|
||||
})
|
||||
|
||||
it('toggleClass adds and removes classes', () => {
|
||||
const el = document.createElement('div')
|
||||
utils.toggleClass(el, 'active')
|
||||
expect(el.classList.contains('active')).toBe(true)
|
||||
utils.toggleClass(el, 'active')
|
||||
expect(el.classList.contains('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('copyText uses clipboard API and notifies success', async () => {
|
||||
utils.copyText('sample')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(clipboardWriteMock).toHaveBeenCalledWith('sample')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.')
|
||||
})
|
||||
|
||||
it('disableOpacity toggles body opacity when enabled', () => {
|
||||
const result = utils.disableOpacity()
|
||||
expect(result).toBe(true)
|
||||
expect(document.body.getAttribute('style')).toBe('opacity: 1.0')
|
||||
})
|
||||
|
||||
it('disableOpacity returns false when background disabled', () => {
|
||||
// Reset any previous style first
|
||||
document.body.removeAttribute('style')
|
||||
|
||||
// The implementation has `if (!bg_enable)` where bg_enable is from useStorage
|
||||
// Since all objects are truthy in JavaScript, this suggests there's a bug in the implementation
|
||||
// or VueUse refs have special behavior. Let's test what the implementation actually does.
|
||||
|
||||
// Clear the storage and set up specific mocks for this test
|
||||
storageMap.clear()
|
||||
|
||||
// Try returning `null` instead of an object - null is falsy
|
||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key === 'random_bg') {
|
||||
return null // null is falsy, so !null will be true
|
||||
}
|
||||
// For other keys, return default storage behavior
|
||||
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()
|
||||
})
|
||||
|
||||
it('enableOpacity applies stored opacity value', () => {
|
||||
// Reset the useStorage mock for this test
|
||||
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.getAttribute('style')).toBe('opacity: 0.75')
|
||||
})
|
||||
})
|
||||
|
||||
describe('network and id helpers', () => {
|
||||
it('request prefixes relative urls and sets defaults', 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('convertCliOptions posts payload and returns parsed json', async () => {
|
||||
const jsonMock = vi.fn().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('convertCliOptions throws on non-200 response', async () => {
|
||||
const jsonMock = vi.fn().mockResolvedValue({ error: 'fail' })
|
||||
const responseMock = { status: 400, json: jsonMock }
|
||||
fetchMock.mockResolvedValue(responseMock)
|
||||
|
||||
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail')
|
||||
})
|
||||
|
||||
it('makeId uses crypto random values for deterministic id', () => {
|
||||
const id = utils.makeId(4)
|
||||
expect(id).toBe('0101')
|
||||
expect(getRandomValuesMock).toHaveBeenCalled()
|
||||
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array
|
||||
expect(typedArray).toBeInstanceOf(Uint8Array)
|
||||
expect(typedArray.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('async helpers', () => {
|
||||
it('sleep resolves after specified seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const promise = utils.sleep(1)
|
||||
const thenSpy = vi.fn()
|
||||
promise.then(thenSpy)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await promise
|
||||
expect(thenSpy).toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('awaiter resolves when test becomes truthy', async () => {
|
||||
// The frequency parameter is passed to sleep() which expects seconds, not milliseconds
|
||||
// So we use 0.01 (10ms) instead of 10 to avoid the bug in the implementation
|
||||
const values = [false, false, 'done']
|
||||
const result = await utils.awaiter(() => values.shift(), 500, 0.01)
|
||||
expect(result).toBe('done')
|
||||
})
|
||||
|
||||
it('awaiter returns false when timeout reached', async () => {
|
||||
// Use a short timeout and small frequency (in seconds, not milliseconds due to implementation bug)
|
||||
const result = await utils.awaiter(() => false, 50, 0.01)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { MatchFilterParser } from './ytdlp'
|
||||
import { MatchFilterParser } from '../../app/utils/ytdlp'
|
||||
|
||||
function normalize(filters: string[]): Set<string> {
|
||||
return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));
|
||||
16
ui/vitest.config.ts
Normal file
16
ui/vitest.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Test files location
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Map ~ to the app directory for imports in tests
|
||||
'~': path.resolve(__dirname, './app'),
|
||||
'#imports': path.resolve(__dirname, './app'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue