diff --git a/.gitignore b/.gitignore index 5d8a2a442..e67c7227d 100644 --- a/.gitignore +++ b/.gitignore @@ -128,7 +128,7 @@ coverage/ /cypress/videos/ /cypress/screenshots/ test-results/ -playwright-report/ +eslint-report.json .codeql-db*/ # Vite @@ -278,5 +278,9 @@ listenarr.api/wwwroot/assets/ listenarr.api/wwwroot/index.html listenarr.api/wwwroot/*.map listenarr.api/wwwroot/assets/** +listenarr.api/wwwroot/large-logo.png +listenarr.api/wwwroot/stats.html +listenarr.api/wwwroot/fonts/.gitkeep +listenarr.api/wwwroot/fonts/README.md listenarr.api/config/appsettings/appsettings.json /listenarr.api/config diff --git a/.husky/pre-commit b/.husky/pre-commit index b1f37d89d..648783953 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,29 @@ #!/usr/bin/env sh set -e +# Some Windows Git clients launch hooks with a reduced PATH that omits Node. +if ! command -v node >/dev/null 2>&1 && [ -d "/c/Program Files/nodejs" ]; then + export PATH="/c/Program Files/nodejs:$PATH" +fi + echo "Running staged lint and format checks..." -npm run lint:staged +node scripts/lint-staged.mjs + +echo "Checking layering rules and async void..." +VIOLATIONS=0 +if git grep -n --no-color "listenarr.infrastructure" -- "listenarr.api/**/*.cs" ":(exclude)listenarr.api/Program.cs" || git grep -n --no-color "Listenarr.Infrastructure" -- "listenarr.api/**/*.cs" ":(exclude)listenarr.api/Program.cs"; then + echo "Layering violation: listenarr.api references listenarr.infrastructure." >&2 + VIOLATIONS=$((VIOLATIONS + 1)) +fi +if git grep -n --no-color "listenarr.infrastructure" -- "listenarr.application/**/*.cs" || git grep -n --no-color "Listenarr.Infrastructure" -- "listenarr.application/**/*.cs"; then + echo "Layering violation: listenarr.application references listenarr.infrastructure." >&2 + VIOLATIONS=$((VIOLATIONS + 1)) +fi +if git grep -n --no-color "async void" -- "listenarr.api/**/*.cs" "listenarr.application/**/*.cs" "listenarr.infrastructure/**/*.cs" "listenarr.domain/**/*.cs"; then + echo "async void found in production code — use async Task instead." >&2 + VIOLATIONS=$((VIOLATIONS + 1)) +fi +if [ "$VIOLATIONS" -gt 0 ]; then + echo "$VIOLATIONS enforcement check(s) failed." >&2 + exit 1 +fi \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index 58a1a635d..2cda20c13 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,19 @@ #!/usr/bin/env sh set -e -echo "Running test suite..." -npm test +# Some Windows Git clients launch hooks with a reduced PATH that omits Node. +if ! command -v node >/dev/null 2>&1 && [ -d "/c/Program Files/nodejs" ]; then + export PATH="/c/Program Files/nodejs:$PATH" +fi + +echo "Syncing version..." +node scripts/sync-fe-version-from-csproj.mjs + +echo "Checking backend format..." +dotnet format listenarr.slnx --no-restore --verify-no-changes --verbosity minimal + +echo "Running frontend type check..." +(cd fe && node node_modules/vue-tsc/bin/vue-tsc.js --build tsconfig.app.json) + +echo "Running frontend tests..." +(cd fe && node node_modules/vitest/vitest.mjs run) \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 144157541..6ee4920de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,7 +144,7 @@ This project follows a layered pattern: domain models in `listenarr.domain`, EF **Testing:** - Run backend tests: `dotnet test` - Run frontend tests: `cd fe && npm run test:unit` -- Run frontend type checks: `cd fe && npm run type-check` +- Run the full frontend gate: `npm run verify:frontend` - Ensure all tests pass before submitting PR ### Branching Model diff --git a/fe/.gitignore b/fe/.gitignore index 9b4ab5c64..fca8ca6f8 100644 --- a/fe/.gitignore +++ b/fe/.gitignore @@ -22,7 +22,7 @@ coverage /cypress/videos/ /cypress/screenshots/ test-results/ -playwright-report/ +eslint-report.json # Editor directories and files .vscode/* diff --git a/fe/.vscode/settings.json b/fe/.vscode/settings.json index 608ad9b05..22887b9d5 100644 --- a/fe/.vscode/settings.json +++ b/fe/.vscode/settings.json @@ -2,7 +2,7 @@ "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { "tsconfig.json": "tsconfig.*.json, env.d.ts", - "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*", + "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*", "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig" }, "editor.codeActionsOnSave": { diff --git a/fe/README.md b/fe/README.md index 2448948ae..5afb3d5f5 100644 --- a/fe/README.md +++ b/fe/README.md @@ -1,6 +1,7 @@ # Listenarr Frontend (fe) -This template should help get you started developing with Vue 3 in Vite. +Listenarr's Vue 3/Vite frontend for audiobook search, library management, +settings, and activity workflows. ## Recommended IDE Setup @@ -47,6 +48,19 @@ npm run build npm run test:unit ``` +This runs all configured Vitest projects: node-only specs, jsdom specs, and +smoke specs. Name specs `*.node.spec.ts` only when they intentionally run +without browser globals. + +### Run the Frontend Verification Gate + +```sh +npm run verify +``` + +This runs the frontend structure guard, ESLint, Vue handler checks, type checks, +and Vitest coverage. + ### Run End-to-End Tests with [Cypress](https://www.cypress.io/) ```sh diff --git a/fe/cypress.config.ts b/fe/cypress.config.ts index 934a79ef9..4a22885c5 100644 --- a/fe/cypress.config.ts +++ b/fe/cypress.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'cypress' export default defineConfig({ e2e: { specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}', - baseUrl: 'http://localhost:5173', + baseUrl: 'http://localhost:4173', }, }) diff --git a/fe/cypress/e2e/file-naming-patterns.cy.ts b/fe/cypress/e2e/file-naming-patterns.cy.ts index 21183a33a..3c94aefdd 100644 --- a/fe/cypress/e2e/file-naming-patterns.cy.ts +++ b/fe/cypress/e2e/file-naming-patterns.cy.ts @@ -1,3 +1,5 @@ +import { apiPath } from '../support/api' + /** * E2E tests for dual file naming pattern feature * Tests single-file vs multi-file pattern selection during imports @@ -6,22 +8,22 @@ describe('File Naming Patterns - Import E2E', () => { beforeEach(() => { // Stub startup config to bypass authentication - cy.intercept('GET', '/api/configuration/startupconfig', { + cy.intercept('GET', apiPath('/configuration/startupconfig'), { statusCode: 200, body: { authenticationRequired: false, apiKey: null, baseUrl: '/', - } + }, }).as('getStartupConfig') - cy.intercept('GET', '/api/account/me', { + cy.intercept('GET', apiPath('/account/me'), { statusCode: 200, - body: { authenticated: false } + body: { authenticated: false }, }).as('getCurrentUser') // Stub application settings with both patterns configured - cy.intercept('GET', '/api/configuration/settings', { + cy.intercept('GET', apiPath('/configuration/settings'), { statusCode: 200, body: { outputPath: '/audiobooks', @@ -33,16 +35,25 @@ describe('File Naming Patterns - Import E2E', () => { pollingIntervalSeconds: 30, enableMetadataProcessing: true, enableCoverArtDownload: true, - } + }, }).as('getSettings') // Stub other required endpoints - cy.intercept('GET', '/api/configuration/apis', { statusCode: 200, body: [] }).as('getApis') - cy.intercept('GET', '/api/configuration/download-clients', { statusCode: 200, body: [] }).as('getDownloadClients') - cy.intercept('GET', '/api/remotepath', { statusCode: 200, body: [] }).as('getRemotePathMappings') - cy.intercept('GET', '/api/indexers', { statusCode: 200, body: [] }).as('getIndexers') - cy.intercept('GET', '/api/qualityprofile', { statusCode: 200, body: [] }).as('getQualityProfiles') - cy.intercept('GET', '/api/account/admins', { statusCode: 200, body: [] }).as('getAdminUsers') + cy.intercept('GET', apiPath('/configuration/apis'), { statusCode: 200, body: [] }).as('getApis') + cy.intercept('GET', apiPath('/configuration/download-clients'), { + statusCode: 200, + body: [], + }).as('getDownloadClients') + cy.intercept('GET', apiPath('/remotepath'), { statusCode: 200, body: [] }).as( + 'getRemotePathMappings', + ) + cy.intercept('GET', apiPath('/indexers'), { statusCode: 200, body: [] }).as('getIndexers') + cy.intercept('GET', apiPath('/qualityprofile'), { statusCode: 200, body: [] }).as( + 'getQualityProfiles', + ) + cy.intercept('GET', apiPath('/account/admins'), { statusCode: 200, body: [] }).as( + 'getAdminUsers', + ) }) describe('Settings UI - Pattern Configuration', () => { @@ -99,7 +110,7 @@ describe('File Naming Patterns - Import E2E', () => { }) it('should update single-file pattern independently', () => { - cy.intercept('POST', '/api/configuration/settings', (req) => { + cy.intercept('POST', apiPath('/configuration/settings'), (req) => { expect(req.body.fileNamingPattern).to.equal('{Author} - {Title}') expect(req.body.multiFileNamingPattern).to.equal('{Title}-{DiskNumber:00}') // Should remain unchanged req.reply({ statusCode: 200, body: req.body }) @@ -109,14 +120,8 @@ describe('File Naming Patterns - Import E2E', () => { cy.wait('@getSettings') // Update single-file pattern - cy.contains('Single File Naming Pattern') - .parent() - .find('input') - .clear() - cy.contains('Single File Naming Pattern') - .parent() - .find('input') - .type('{Author} - {Title}') + cy.contains('Single File Naming Pattern').parent().find('input').clear() + cy.contains('Single File Naming Pattern').parent().find('input').type('{Author} - {Title}') // Save settings cy.contains('button', 'Save').click() @@ -124,7 +129,7 @@ describe('File Naming Patterns - Import E2E', () => { }) it('should update multi-file pattern independently', () => { - cy.intercept('POST', '/api/configuration/settings', (req) => { + cy.intercept('POST', apiPath('/configuration/settings'), (req) => { expect(req.body.fileNamingPattern).to.equal('{Title}') // Should remain unchanged expect(req.body.multiFileNamingPattern).to.equal('{Title} Part {DiskNumber}') req.reply({ statusCode: 200, body: req.body }) @@ -134,10 +139,7 @@ describe('File Naming Patterns - Import E2E', () => { cy.wait('@getSettings') // Update multi-file pattern - cy.contains('Multi-File Naming Pattern') - .parent() - .find('input') - .clear() + cy.contains('Multi-File Naming Pattern').parent().find('input').clear() cy.contains('Multi-File Naming Pattern') .parent() .find('input') @@ -185,25 +187,25 @@ describe('File Naming Patterns - Import E2E', () => { describe('Manual Import - Pattern Selection', () => { it('should use single-file pattern for audiobooks without disk numbers', () => { // Stub the manual import endpoint - cy.intercept('POST', '/api/manualimport/start', (req) => { + cy.intercept('POST', apiPath('/manualimport/start'), (req) => { // Verify request doesn't include disk numbers expect(req.body.items).to.be.an('array') expect(req.body.items[0]).to.not.have.property('diskNumber') - + req.reply({ statusCode: 200, body: { success: true, message: 'Import started', // Simulate backend using FileNamingPattern (single file) - destinationPath: '/audiobooks/Stephen King/The Gunslinger.m4b' - } + destinationPath: '/audiobooks/Stephen King/The Gunslinger.m4b', + }, }) }).as('startImport') // Simulate manual import workflow cy.visit('/library/import') - + // (Simplified - actual UI may require more interaction) // Verify that single-file import results in simple naming cy.wait('@startImport').then((interception) => { @@ -215,7 +217,7 @@ describe('File Naming Patterns - Import E2E', () => { it('should use multi-file pattern for audiobooks with disk numbers', () => { // Stub the manual import endpoint - cy.intercept('POST', '/api/manualimport/start', (req) => { + cy.intercept('POST', apiPath('/manualimport/start'), (req) => { // Verify request includes disk numbers expect(req.body.items).to.be.an('array') if (req.body.items.length > 0 && req.body.items[0].diskNumber) { @@ -228,15 +230,15 @@ describe('File Naming Patterns - Import E2E', () => { destinationPaths: [ '/audiobooks/Stephen King/The Gunslinger-01.m4b', '/audiobooks/Stephen King/The Gunslinger-02.m4b', - '/audiobooks/Stephen King/The Gunslinger-03.m4b' - ] - } + '/audiobooks/Stephen King/The Gunslinger-03.m4b', + ], + }, }) } }).as('startMultiFileImport') cy.visit('/library/import') - + // Verify multi-file import uses disk-numbered pattern cy.wait('@startMultiFileImport').then((interception) => { const response = interception.response?.body @@ -250,7 +252,7 @@ describe('File Naming Patterns - Import E2E', () => { describe('Download Processing - Pattern Selection', () => { it('should apply single-file pattern to completed single-file downloads', () => { // Stub download completion processing - cy.intercept('GET', '/api/downloads/queue', { + cy.intercept('GET', apiPath('/downloads/queue'), { statusCode: 200, body: [ { @@ -259,12 +261,12 @@ describe('File Naming Patterns - Import E2E', () => { status: 'Completed', audiobook: { title: 'The Hobbit', author: 'J.R.R. Tolkien' }, // No diskNumber indicates single file - progress: 100 - } - ] + progress: 100, + }, + ], }).as('getQueue') - cy.intercept('GET', '/api/downloads/history', { + cy.intercept('GET', apiPath('/downloads/history'), { statusCode: 200, body: [ { @@ -273,9 +275,9 @@ describe('File Naming Patterns - Import E2E', () => { status: 'Moved', audiobook: { title: 'The Hobbit', author: 'J.R.R. Tolkien' }, // Verify the file was named using single-file pattern - destinationPath: '/audiobooks/J.R.R. Tolkien/The Hobbit.m4b' - } - ] + destinationPath: '/audiobooks/J.R.R. Tolkien/The Hobbit.m4b', + }, + ], }).as('getHistory') cy.visit('/downloads') @@ -288,7 +290,7 @@ describe('File Naming Patterns - Import E2E', () => { it('should apply multi-file pattern to completed multi-disk downloads', () => { // Stub download completion processing for multi-disk audiobook - cy.intercept('GET', '/api/downloads/history', { + cy.intercept('GET', apiPath('/downloads/history'), { statusCode: 200, body: [ { @@ -300,10 +302,10 @@ describe('File Naming Patterns - Import E2E', () => { destinationPaths: [ '/audiobooks/J.R.R. Tolkien/The Lord of the Rings-01.m4b', '/audiobooks/J.R.R. Tolkien/The Lord of the Rings-02.m4b', - '/audiobooks/J.R.R. Tolkien/The Lord of the Rings-03.m4b' - ] - } - ] + '/audiobooks/J.R.R. Tolkien/The Lord of the Rings-03.m4b', + ], + }, + ], }).as('getMultiFileHistory') cy.visit('/downloads') @@ -323,14 +325,8 @@ describe('File Naming Patterns - Import E2E', () => { cy.wait('@getSettings') // Change multi-file pattern to one without disk number - cy.contains('Multi-File Naming Pattern') - .parent() - .find('input') - .clear() - cy.contains('Multi-File Naming Pattern') - .parent() - .find('input') - .type('{Title}') // Same as single-file pattern - problematic! + cy.contains('Multi-File Naming Pattern').parent().find('input').clear() + cy.contains('Multi-File Naming Pattern').parent().find('input').type('{Title}') // Same as single-file pattern - problematic! // Preview should show warning cy.contains('Multi-File Naming Pattern') @@ -363,10 +359,7 @@ describe('File Naming Patterns - Import E2E', () => { cy.wait('@getSettings') // Change to use chapter numbers - cy.contains('Multi-File Naming Pattern') - .parent() - .find('input') - .clear() + cy.contains('Multi-File Naming Pattern').parent().find('input').clear() cy.contains('Multi-File Naming Pattern') .parent() .find('input') @@ -386,45 +379,43 @@ describe('File Naming Patterns - Import E2E', () => { describe('Integration - Full Import Workflow', () => { it('should complete end-to-end single-file import with correct naming', () => { // Mock the full workflow - cy.intercept('GET', '/api/filesystem/browse?path=*', { + cy.intercept('GET', apiPath('/filesystem/browse?path=*'), { statusCode: 200, body: { currentPath: '/source', - files: [ - { name: 'audiobook.m4b', size: 1048576, isDirectory: false } - ], - directories: [] - } + files: [{ name: 'audiobook.m4b', size: 1048576, isDirectory: false }], + directories: [], + }, }).as('browseFiles') - cy.intercept('GET', '/api/v*/metadata/*', { + cy.intercept('GET', apiPath('/metadata/*'), { statusCode: 200, body: { title: 'Project Hail Mary', author: 'Andy Weir', - asin: 'B08G9PRS1K' - } + asin: 'B08G9PRS1K', + }, }).as('getMetadata') - cy.intercept('POST', '/api/manualimport/start', (req) => { + cy.intercept('POST', apiPath('/manualimport/start'), (req) => { const item = req.body.items[0] // Single file - no disk number void expect(item.diskNumber).to.be.undefined - + req.reply({ statusCode: 200, body: { success: true, // Backend should use single-file pattern - destinationPath: '/audiobooks/Andy Weir/Project Hail Mary.m4b' - } + destinationPath: '/audiobooks/Andy Weir/Project Hail Mary.m4b', + }, }) }).as('startSingleImport') // Visit import page and complete workflow cy.visit('/library/import') // (Simplified - actual UI workflow would involve file selection, etc.) - + cy.wait('@startSingleImport').then((interception) => { const response = interception.response?.body // Verify single-file naming (no disk number suffix) @@ -435,35 +426,35 @@ describe('File Naming Patterns - Import E2E', () => { it('should complete end-to-end multi-file import with correct naming', () => { // Mock multi-file workflow - cy.intercept('GET', '/api/filesystem/browse?path=*', { + cy.intercept('GET', apiPath('/filesystem/browse?path=*'), { statusCode: 200, body: { currentPath: '/source', files: [ { name: 'audiobook-01.m4b', size: 1048576, isDirectory: false }, { name: 'audiobook-02.m4b', size: 1048576, isDirectory: false }, - { name: 'audiobook-03.m4b', size: 1048576, isDirectory: false } + { name: 'audiobook-03.m4b', size: 1048576, isDirectory: false }, ], - directories: [] - } + directories: [], + }, }).as('browseMultiFiles') - cy.intercept('GET', '/api/v*/metadata/*', { + cy.intercept('GET', apiPath('/metadata/*'), { statusCode: 200, body: { title: 'The Way of Kings', author: 'Brandon Sanderson', - asin: 'B003ZWFO7E' - } + asin: 'B003ZWFO7E', + }, }).as('getMultiMetadata') - cy.intercept('POST', '/api/manualimport/start', (req) => { + cy.intercept('POST', apiPath('/manualimport/start'), (req) => { // Multi-file import should include disk numbers expect(req.body.items).to.have.length.greaterThan(1) req.body.items.forEach((item: { diskNumber?: number }, index: number) => { expect(item.diskNumber).to.equal(index + 1) }) - + req.reply({ statusCode: 200, body: { @@ -472,14 +463,14 @@ describe('File Naming Patterns - Import E2E', () => { destinationPaths: [ '/audiobooks/Brandon Sanderson/The Way of Kings-01.m4b', '/audiobooks/Brandon Sanderson/The Way of Kings-02.m4b', - '/audiobooks/Brandon Sanderson/The Way of Kings-03.m4b' - ] - } + '/audiobooks/Brandon Sanderson/The Way of Kings-03.m4b', + ], + }, }) }).as('startMultiImport') cy.visit('/library/import') - + cy.wait('@startMultiImport').then((interception) => { const response = interception.response?.body // Verify multi-file naming with disk numbers diff --git a/fe/cypress/e2e/hardlink-move-flow.cy.ts b/fe/cypress/e2e/hardlink-move-flow.cy.ts index b771b8d93..22003d6f7 100644 --- a/fe/cypress/e2e/hardlink-move-flow.cy.ts +++ b/fe/cypress/e2e/hardlink-move-flow.cy.ts @@ -1,28 +1,33 @@ +import { apiPath } from '../support/api' + /* eslint-disable cypress/unsafe-to-chain-command */ describe('Hardlink/Copy Move Flow (E2E)', () => { beforeEach(() => { // Stub startup config and account checks (no auth) - cy.intercept('GET', '/api/configuration/startupconfig', { + cy.intercept('GET', apiPath('/configuration/startupconfig'), { statusCode: 200, - body: { authenticationRequired: false, apiKey: null, baseUrl: '/' } + body: { authenticationRequired: false, apiKey: null, baseUrl: '/' }, }).as('getStartupConfig') - cy.intercept('GET', '/api/account/me', { statusCode: 200, body: { authenticated: false } }).as('getCurrentUser') + cy.intercept('GET', apiPath('/account/me'), { + statusCode: 200, + body: { authenticated: false }, + }).as('getCurrentUser') // App settings with outputPath and default file handling mode (Hardlink/Copy) - cy.intercept('GET', '/api/configuration/settings', { + cy.intercept('GET', apiPath('/configuration/settings'), { statusCode: 200, body: { outputPath: '/mnt/audiobooks', fileNamingPattern: '{Author}/{Title}', completedFileAction: 'Hardlink/Copy', maxConcurrentDownloads: 2, - pollingIntervalSeconds: 30 - } + pollingIntervalSeconds: 30, + }, }).as('getSettings') // Stub library endpoint to return a single audiobook - cy.intercept('GET', '/api/library', { + cy.intercept('GET', apiPath('/library'), { statusCode: 200, body: [ { @@ -34,39 +39,45 @@ describe('Hardlink/Copy Move Flow (E2E)', () => { qualityProfileId: null, tags: [], abridged: false, - explicit: false - } - ] + explicit: false, + }, + ], }).as('getLibrary') // Stub other endpoints - cy.intercept('GET', '/api/qualityprofile', { statusCode: 200, body: [] }).as('getProfiles') - cy.intercept('GET', '/api/configuration/apis', { statusCode: 200, body: [] }).as('getApis') - cy.intercept('GET', '/api/configuration/download-clients', { statusCode: 200, body: [] }).as('getDownloadClients') + cy.intercept('GET', apiPath('/qualityprofile'), { statusCode: 200, body: [] }).as('getProfiles') + cy.intercept('GET', apiPath('/configuration/apis'), { statusCode: 200, body: [] }).as('getApis') + cy.intercept('GET', apiPath('/configuration/download-clients'), { + statusCode: 200, + body: [], + }).as('getDownloadClients') // Capture the PUT update request for assertions - cy.intercept('PUT', '/api/library/1', (req) => { + cy.intercept('PUT', apiPath('/library/1'), (req) => { req.reply((res) => { - const updated = Object.assign({ id: 1, title: 'Test Book', author: 'Test Author' }, req.body) + const updated = Object.assign( + { id: 1, title: 'Test Book', author: 'Test Author' }, + req.body, + ) res.send({ statusCode: 200, body: { message: 'ok', audiobook: updated } }) }) }).as('updateAudiobook') // Capture move request with fileHandling mode - cy.intercept('POST', '/api/library/1/move', (req) => { + cy.intercept('POST', apiPath('/library/1/move'), (req) => { req.reply({ statusCode: 200, body: { message: 'queued', jobId: 'job-test-1' } }) }).as('moveAudiobook') // Stub volume check endpoint - cy.intercept('GET', '/api/filesystem/check-volume*', { + cy.intercept('GET', apiPath('/filesystem/check-volume*'), { statusCode: 200, body: { sameVolume: true, willBreakHardlinks: false, sourceVolume: '/mnt', destVolume: '/mnt', - message: 'Same volume' - } + message: 'Same volume', + }, }).as('checkVolume') }) diff --git a/fe/cypress/e2e/move-flow.cy.ts b/fe/cypress/e2e/move-flow.cy.ts index 72a681a48..af5ca717d 100644 --- a/fe/cypress/e2e/move-flow.cy.ts +++ b/fe/cypress/e2e/move-flow.cy.ts @@ -1,24 +1,29 @@ +import { apiPath } from '../support/api' + /* eslint-disable cypress/unsafe-to-chain-command */ describe('Edit -> Move flow (E2E)', () => { beforeEach(() => { // Stub startup config and account checks (no auth) - cy.intercept('GET', '/api/configuration/startupconfig', { + cy.intercept('GET', apiPath('/configuration/startupconfig'), { statusCode: 200, - body: { authenticationRequired: false, apiKey: null, baseUrl: '/' } + body: { authenticationRequired: false, apiKey: null, baseUrl: '/' }, }).as('getStartupConfig') - cy.intercept('GET', '/api/account/me', { statusCode: 200, body: { authenticated: false } }).as('getCurrentUser') + cy.intercept('GET', apiPath('/account/me'), { + statusCode: 200, + body: { authenticated: false }, + }).as('getCurrentUser') // App settings with outputPath configured - cy.intercept('GET', '/api/configuration/settings', { + cy.intercept('GET', apiPath('/configuration/settings'), { statusCode: 200, body: { - outputPath: '/mnt/audiobooks' - } + outputPath: '/mnt/audiobooks', + }, }).as('getSettings') // Stub library endpoint to return a single audiobook - cy.intercept('GET', '/api/library', { + cy.intercept('GET', apiPath('/library'), { statusCode: 200, body: [ { @@ -30,25 +35,28 @@ describe('Edit -> Move flow (E2E)', () => { qualityProfileId: null, tags: [], abridged: false, - explicit: false - } - ] + explicit: false, + }, + ], }).as('getLibrary') // Stub quality profiles / other endpoints minimally - cy.intercept('GET', '/api/qualityprofile', { statusCode: 200, body: [] }).as('getProfiles') + cy.intercept('GET', apiPath('/qualityprofile'), { statusCode: 200, body: [] }).as('getProfiles') // Capture the PUT update request for assertions - cy.intercept('PUT', '/api/library/1', (req) => { + cy.intercept('PUT', apiPath('/library/1'), (req) => { req.reply((res) => { // Respond with updated audiobook by echoing payload - const updated = Object.assign({ id: 1, title: 'Test Book', author: 'Test Author' }, req.body) + const updated = Object.assign( + { id: 1, title: 'Test Book', author: 'Test Author' }, + req.body, + ) res.send({ statusCode: 200, body: { message: 'ok', audiobook: updated } }) }) }).as('updateAudiobook') // Capture move request and return job id - cy.intercept('POST', '/api/library/1/move', (req) => { + cy.intercept('POST', apiPath('/library/1/move'), (req) => { req.reply({ statusCode: 200, body: { message: 'queued', jobId: 'job-test-1' } }) }).as('moveAudiobook') }) @@ -95,7 +103,7 @@ describe('Edit -> Move flow (E2E)', () => { it('edits destination and chooses "Change without moving" to update DB only', () => { // Override move intercept to fail the test if called - cy.intercept('POST', '/api/library/1/move', () => { + cy.intercept('POST', apiPath('/library/1/move'), () => { throw new Error('Move API should not be called when user selects Change without moving') }).as('moveShouldNotBeCalled') @@ -121,11 +129,13 @@ describe('Edit -> Move flow (E2E)', () => { cy.get('.confirm-dialog .btn').contains('Change without moving').click() // Ensure update endpoint was called with expected payload - cy.wait('@updateAudiobook').its('request.body').then((body) => { - expect(body.basePath).to.equal('/mnt/audiobooks/New Author/New Book') - }) + cy.wait('@updateAudiobook') + .its('request.body') + .then((body) => { + expect(body.basePath).to.equal('/mnt/audiobooks/New Author/New Book') + }) // Expect a toast informing destination updated without moving cy.contains('Destination updated', { timeout: 5000 }).should('exist') }) -}) \ No newline at end of file +}) diff --git a/fe/cypress/e2e/settings-root-folders.cy.ts b/fe/cypress/e2e/settings-root-folders.cy.ts index 182a7ce09..23a4f4ce5 100644 --- a/fe/cypress/e2e/settings-root-folders.cy.ts +++ b/fe/cypress/e2e/settings-root-folders.cy.ts @@ -1,13 +1,17 @@ +import { apiPath } from '../support/api' + /* eslint-disable cypress/unsafe-to-chain-command */ describe('Root Folders Settings', () => { beforeEach(() => { - cy.intercept('GET', '/api/rootfolders', { body: [ { id: 1, name: 'Root1', path: 'C:\\root' } ] }).as('getRoots') + cy.intercept('GET', apiPath('/rootfolders'), { + body: [{ id: 1, name: 'Root1', path: 'C:\\root' }], + }).as('getRoots') cy.visit('/settings') cy.wait('@getRoots') }) it('renames root without moving (DB-only)', () => { - cy.intercept('PUT', '/api/rootfolders/1*', (req) => { + cy.intercept('PUT', apiPath('/rootfolders/1*'), (req) => { req.reply({ statusCode: 200, body: { id: 1, name: 'Root1', path: 'D:\\newroot' } }) }).as('putRoot') @@ -30,7 +34,7 @@ describe('Root Folders Settings', () => { }) it('renames root and queues moves when Move selected', () => { - cy.intercept('PUT', '/api/rootfolders/1*', (req) => { + cy.intercept('PUT', apiPath('/rootfolders/1*'), (req) => { // Simulate backend accepting move request req.reply({ statusCode: 200, body: { id: 1, name: 'Root1', path: 'E:\\moved' } }) }).as('putRootMove') @@ -47,6 +51,8 @@ describe('Root Folders Settings', () => { cy.contains('Move').click() cy.wait('@putRootMove').its('request.url').should('contain', 'moveFiles=true') - cy.wait('@putRootMove').its('request.body').should('include', { name: 'Root1', path: 'E\\moved' }) + cy.wait('@putRootMove') + .its('request.body') + .should('include', { name: 'Root1', path: 'E\\moved' }) }) -}) \ No newline at end of file +}) diff --git a/fe/cypress/e2e/settings.cy.ts b/fe/cypress/e2e/settings.cy.ts index d4854c7ef..5cbe2f539 100644 --- a/fe/cypress/e2e/settings.cy.ts +++ b/fe/cypress/e2e/settings.cy.ts @@ -1,91 +1,73 @@ - +import { apiPath, stubAppShellApi } from '../support/api' + describe('Settings UI - e2e', () => { beforeEach(() => { + stubAppShellApi() + // Stub startup config to indicate authentication is NOT required so the // SPA won't redirect to the login page during tests. - cy.intercept('GET', '/api/configuration/startupconfig', { + cy.intercept('GET', apiPath('/configuration/startupconfig'), { statusCode: 200, body: { authenticationRequired: false, apiKey: null, baseUrl: '/', - } + }, }).as('getStartupConfig') - // Some dev setups may request the startup config without the /api prefix; stub that too - cy.intercept('GET', '/configuration/startupconfig', { - statusCode: 200, - body: { - authenticationRequired: false, - apiKey: null, - baseUrl: '/', - } - }).as('getStartupConfigNoApi') - // Stub account/me to return an unauthenticated but non-redirecting response // (the SPA treats this as not requiring a login here). - cy.intercept('GET', '/api/account/me', { + cy.intercept('GET', apiPath('/account/me'), { statusCode: 200, - body: { authenticated: false } + body: { authenticated: false }, }).as('getCurrentUser') - // Also stub account/me without /api in case the SPA requests a bare path - cy.intercept('GET', '/account/me', { - statusCode: 200, - body: { authenticated: false } - }).as('getCurrentUserNoApi') - - // Intercept the GET for application settings and return a baseline where outputPath is empty - cy.intercept('GET', '/api/configuration/settings', { + // Intercept the GET for application settings and return the baseline used by the general tab. + cy.intercept('GET', apiPath('/configuration/settings'), { statusCode: 200, body: { - preferUsDomain: false, - useUsProxy: false, - usProxyHost: '', - usProxyPort: 0, - usProxyUsername: '', - usProxyPassword: '', - outputPath: '', - fileNamingPattern: '{Author}/{Title}', - completedFileAction: 'Move', + outputPath: '/mnt/audiobooks', + folderNamingPattern: '{Author}/{Series}/{Title}', + fileNamingPattern: '{Title}', + multiFileNamingPattern: '{Title}-{DiskNumber:00}', + completedFileAction: 'copy', maxConcurrentDownloads: 2, pollingIntervalSeconds: 30, - } + enableOpenLibrarySearch: true, + defaultSearchRegion: 'us', + defaultSearchLanguage: 'english', + }, }).as('getSettings') - // Also accept the same settings path without /api - cy.intercept('GET', '/configuration/settings', { - statusCode: 200, - body: { - preferUsDomain: false, - useUsProxy: false, - usProxyHost: '', - usProxyPort: 0, - usProxyUsername: '', - usProxyPassword: '', - outputPath: '', - fileNamingPattern: '{Author}/{Title}', - completedFileAction: 'Move', - maxConcurrentDownloads: 2, - pollingIntervalSeconds: 30, - } - }).as('getSettingsNoApi') - // Stub other startup endpoints the Settings page loads so Promise.all settles - cy.intercept('GET', '/api/configuration/apis', { statusCode: 200, body: [] }).as('getApis') - cy.intercept('GET', '/api/configuration/download-clients', { statusCode: 200, body: [] }).as('getDownloadClients') - cy.intercept('GET', '/api/remotepath', { statusCode: 200, body: [] }).as('getRemotePathMappings') - cy.intercept('GET', '/api/indexers', { statusCode: 200, body: [] }).as('getIndexers') - cy.intercept('GET', '/api/qualityprofile', { statusCode: 200, body: [] }).as('getQualityProfiles') - cy.intercept('GET', '/api/account/admins', { statusCode: 200, body: [] }).as('getAdminUsers') + cy.intercept('GET', apiPath('/configuration/apis'), { statusCode: 200, body: [] }).as('getApis') + cy.intercept('GET', apiPath('/configuration/download-clients'), { + statusCode: 200, + body: [], + }).as('getDownloadClients') + cy.intercept('GET', apiPath('/remotepath'), { statusCode: 200, body: [] }).as( + 'getRemotePathMappings', + ) + cy.intercept('GET', apiPath('/indexers'), { statusCode: 200, body: [] }).as('getIndexers') + cy.intercept('GET', apiPath('/qualityprofile'), { statusCode: 200, body: [] }).as( + 'getQualityProfiles', + ) + cy.intercept('GET', apiPath('/account/admins'), { statusCode: 200, body: [] }).as( + 'getAdminUsers', + ) // Intercept save and assert payload - cy.intercept('POST', '/api/configuration/settings', (req) => { + cy.intercept('POST', apiPath('/configuration/settings'), (req) => { req.reply((res) => { // Respond with the same payload to simulate persistence res.send({ statusCode: 200, body: req.body }) }) }).as('saveSettings') + + cy.intercept('POST', apiPath('/configuration/startupconfig'), { + statusCode: 200, + body: { success: true }, + }).as('saveStartupConfig') }) // On failure, save the current page HTML and a screenshot to help diagnose @@ -107,80 +89,34 @@ describe('Settings UI - e2e', () => { } }) - it('fills required fields, enables proxy, and saves settings', () => { - // Visit the home page first to see if RouterView works - cy.visit('/', { timeout: 10000 }) - - // Check that the app is mounted - cy.get('#app', { timeout: 10000 }).should('exist') - cy.log('Home page loaded successfully') - - // Wait for the main navigation to appear so the app has mounted and initial requests completed - cy.contains('Settings', { timeout: 10000 }).should('be.visible') - - // Wait for the SPA startup requests (startup config + settings) so the app is initialized - cy.wait(['@getStartupConfig', '@getSettings'], { timeout: 20000 }) + it('updates file naming settings and saves general settings', () => { + cy.visit('/settings#general', { timeout: 10000 }) - // Click the Settings link in the sidebar (visible) to navigate via the app UI - cy.contains('Settings', { timeout: 10000 }).should('be.visible').click() + cy.wait(['@getStartupConfig', '@getSettings'], { timeout: 20000 }) + cy.get('.settings-page', { timeout: 20000 }).should('exist') + cy.url({ timeout: 10000 }).should('include', '/settings') - // Ensure the settings page DOM is present - cy.get('.settings-page', { timeout: 20000 }).should('exist') + cy.get('.general-settings-tab .section-header h3', { timeout: 10000 }).should( + 'contain', + 'General Settings', + ) - // Check that the app is still mounted - cy.get('#app', { timeout: 10000 }).should('exist') - cy.log('App still mounted after navigation to settings') - - // Assert the URL includes /settings - cy.url({ timeout: 10000 }).should('include', '/settings') - cy.log('URL includes /settings') - - // Ensure the settings page main content is rendered - cy.get('.main-content', { timeout: 20000 }).should('exist') - cy.log('Main content exists') - - // Check what's in the main content - cy.get('.main-content').invoke('html').then(html => { - cy.log('Main content HTML:', html.substring(0, 500)) - }) - - cy.get('.settings-page').should('exist') - - // Click on the "General Settings" tab to show the form and wait for the form input to appear - cy.contains('General Settings').click() - // Ensure the general settings form is rendered by checking the output-path input - // Increase timeout as the SPA may take longer to fetch required data. - cy.get('input[placeholder="Select a folder for audiobooks..."]', { timeout: 20000 }).should('exist') - - - // Output path: use the folder browser input - // Use the folder browser input placeholder to locate the field reliably in the SPA - cy.get('input[placeholder="Select a folder for audiobooks..."]').clear() - cy.get('input[placeholder="Select a folder for audiobooks..."]').type('/mnt/audiobooks') - - // Enable proxy - cy.contains('Use HTTP proxy for US requests').parent().find('input[type="checkbox"]').check() - - // Fill proxy host and port (select the port input by its label to avoid hitting other number inputs) - // Use stable data-cy selectors for proxy host/port - cy.get('[data-cy="us-proxy-host"]').clear() - cy.get('[data-cy="us-proxy-host"]').type('proxy.test.local') - cy.get('[data-cy="us-proxy-port"]').clear() - cy.get('[data-cy="us-proxy-port"]').type('3128') + cy.contains('Single File Naming Pattern').parent().find('input').as('singleFilePatternInput') + cy.get('@singleFilePatternInput').clear() + cy.get('@singleFilePatternInput').type('{Author} - {Title}', { + parseSpecialCharSequences: false, + }) + cy.get('@singleFilePatternInput').blur() - // Click Save - cy.contains('Save Settings').click() + cy.contains('button', 'Save Settings').click() // Confirm save request was made with expected payload cy.wait('@saveSettings').then((interception) => { const body = interception.request.body - expect(body.outputPath).to.equal('/mnt/audiobooks') - expect(body.useUsProxy).to.equal(true) - expect(body.usProxyHost).to.equal('proxy.test.local') - expect(Number(body.usProxyPort)).to.equal(3128) + expect(body.fileNamingPattern).to.equal('{Author} - {Title}') + expect(body.multiFileNamingPattern).to.equal('{Title}-{DiskNumber:00}') }) - // Optionally assert UI shows success toast (depends on toast implementation) cy.contains('Settings saved successfully').should('exist') }) }) diff --git a/fe/cypress/support/api.ts b/fe/cypress/support/api.ts new file mode 100644 index 000000000..7cb2f8138 --- /dev/null +++ b/fe/cypress/support/api.ts @@ -0,0 +1,31 @@ +export const apiPath = (endpoint: string): string => { + const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}` + return `/api/v*${normalized}` +} + +export const stubAppShellApi = (): void => { + cy.intercept('GET', apiPath('/system/health'), { + statusCode: 200, + body: { status: 'healthy', version: '0.0.0-e2e' }, + }).as('getSystemHealth') + + cy.intercept('GET', apiPath('/antiforgery/token'), { + statusCode: 200, + body: { token: 'cypress-antiforgery-token' }, + }).as('getAntiforgeryToken') + + cy.intercept('GET', apiPath('/download/queue'), { + statusCode: 200, + body: { items: [], totalCount: 0 }, + }).as('getDownloadQueue') + + cy.intercept('GET', apiPath('/library'), { + statusCode: 200, + body: [], + }).as('getLibrary') + + cy.intercept('GET', apiPath('/rootfolders'), { + statusCode: 200, + body: [], + }).as('getRootFolders') +} diff --git a/fe/eslint-report.json b/fe/eslint-report.json deleted file mode 100644 index 5ede394f1..000000000 --- a/fe/eslint-report.json +++ /dev/null @@ -1 +0,0 @@ -[{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\example.cy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\file-naming-patterns.cy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\hardlink-move-flow.cy.ts","messages":[],"suppressedMessages":[{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":86,"column":5,"messageId":"unexpected","endLine":86,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":109,"column":5,"messageId":"unexpected","endLine":109,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":136,"column":5,"messageId":"unexpected","endLine":136,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":163,"column":5,"messageId":"unexpected","endLine":163,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\move-flow.cy.ts","messages":[],"suppressedMessages":[{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":70,"column":5,"messageId":"unexpected","endLine":70,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":114,"column":5,"messageId":"unexpected","endLine":114,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\screenshot\\general-settings.spec.ts","messages":[{"ruleId":"cypress/no-unnecessary-waiting","severity":2,"message":"Do not wait for arbitrary time periods","line":13,"column":5,"messageId":"unexpected","endLine":13,"endColumn":17}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"describe('General Settings visual check', () => {\n it('captures the General Settings tab (desktop)', () => {\n // Visit the running dev server (adjust host/port if your dev server uses a different port)\n cy.visit('http://localhost:5173/settings#general')\n\n // Wait for the main settings panel to appear (increase timeout)\n cy.get('.general-settings-tab', { timeout: 15000 }).should('be.visible')\n\n // Ensure specific content has rendered: File Naming Pattern\n cy.contains('File Naming Pattern', { timeout: 15000 }).should('be.visible')\n\n // Small delay to allow fonts/assets to stabilize briefly\n cy.wait(400)\n\n // Take a full-page screenshot\n cy.screenshot('general-settings-fullpage', { capture: 'fullPage' })\n\n // Also capture the File Management card specifically\n cy.get('.form-section').first().screenshot('general-settings-file-management')\n })\n})","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\settings-root-folders.cy.ts","messages":[],"suppressedMessages":[{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":20,"column":5,"messageId":"unexpected","endLine":20,"endColumn":69,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"cypress/unsafe-to-chain-command","severity":2,"message":"It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.","line":43,"column":5,"messageId":"unexpected","endLine":43,"endColumn":69,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\e2e\\settings.cy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\support\\commands.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\cypress\\support\\e2e.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\env.d.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\eslint.config.ts","messages":[],"suppressedMessages":[{"ruleId":"@typescript-eslint/ban-ts-comment","severity":2,"message":"Use \"@ts-expect-error\" instead of \"@ts-ignore\", as \"@ts-ignore\" will do nothing if the following line is error-free.","line":6,"column":1,"messageId":"tsIgnoreInsteadOfExpectError","endLine":6,"endColumn":14,"suggestions":[{"messageId":"replaceTsIgnoreWithTsExpectError","fix":{"range":[283,296],"text":"// @ts-expect-error"},"desc":"Replace \"@ts-ignore\" with \"@ts-expect-error\"."}],"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\scripts\\ssr-import.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\App.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ActivityView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AddLibraryModal.accessibility.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AddLibraryModal.relativePath.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AddNewView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ApiKeyControl.spec.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'apiService' is defined but never used.","line":5,"column":10,"messageId":"unusedVar","endLine":5,"endColumn":20,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"apiService"},"fix":{"range":[167,211],"text":""},"desc":"Remove unused import declaration."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'useConfirmModule' is defined but never used.","line":6,"column":13,"messageId":"unusedVar","endLine":6,"endColumn":29,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"useConfirmModule"},"fix":{"range":[211,272],"text":""},"desc":"Remove unused import declaration."}]},{"ruleId":"@typescript-eslint/ban-ts-comment","severity":2,"message":"Use \"@ts-expect-error\" instead of \"@ts-ignore\", as \"@ts-ignore\" will do nothing if the following line is error-free.","line":17,"column":5,"messageId":"tsIgnoreInsteadOfExpectError","endLine":17,"endColumn":43,"suggestions":[{"messageId":"replaceTsIgnoreWithTsExpectError","fix":{"range":[587,625],"text":"// @ts-expect-error - provide fake clipboard"},"desc":"Replace \"@ts-ignore\" with \"@ts-expect-error\"."}]},{"ruleId":"@typescript-eslint/ban-ts-comment","severity":2,"message":"Use \"@ts-expect-error\" instead of \"@ts-ignore\", as \"@ts-ignore\" will do nothing if the following line is error-free.","line":35,"column":5,"messageId":"tsIgnoreInsteadOfExpectError","endLine":35,"endColumn":18,"suggestions":[{"messageId":"replaceTsIgnoreWithTsExpectError","fix":{"range":[1243,1256],"text":"// @ts-expect-error"},"desc":"Replace \"@ts-ignore\" with \"@ts-expect-error\"."}]},{"ruleId":"@typescript-eslint/no-unsafe-function-type","severity":2,"message":"The `Function` type accepts any function-like value.\nPrefer explicitly defining any function parameters and return type.","line":56,"column":39,"messageId":"bannedFunctionType","endLine":56,"endColumn":47},{"ruleId":"@typescript-eslint/ban-ts-comment","severity":2,"message":"Use \"@ts-expect-error\" instead of \"@ts-ignore\", as \"@ts-ignore\" will do nothing if the following line is error-free.","line":78,"column":5,"messageId":"tsIgnoreInsteadOfExpectError","endLine":78,"endColumn":18,"suggestions":[{"messageId":"replaceTsIgnoreWithTsExpectError","fix":{"range":[2932,2945],"text":"// @ts-expect-error"},"desc":"Replace \"@ts-ignore\" with \"@ts-expect-error\"."}]}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { mount } from '@vue/test-utils'\nimport PasswordInput from '@/components/form/PasswordInput.vue'\n\nimport { apiService } from '@/services/api'\nimport * as useConfirmModule from '@/composables/useConfirm'\n\ndescribe('ApiKeyControl', () => {\n beforeEach(async () => {\n vi.restoreAllMocks()\n // Reset imported modules so doMock can take effect for each test\n vi.resetModules()\n })\n\n it('copies to clipboard when copy button clicked', async () => {\n const writeMock = vi.fn().mockResolvedValue(undefined)\n // @ts-ignore - provide fake clipboard\n global.navigator = { clipboard: { writeText: writeMock } } as unknown\n\n const { default: ApiKeyControl } = await import('@/components/ui/ApiKeyControl.vue')\n const wrapper = mount(ApiKeyControl, {\n props: { apiKey: 'MYKEY' },\n global: { components: { PasswordInput } },\n })\n\n const copyBtn = wrapper.find('button.copy-btn')\n expect(copyBtn.exists()).toBe(true)\n\n await copyBtn.trigger('click')\n expect(writeMock).toHaveBeenCalledWith('MYKEY')\n })\n\n it('regenerates key and emits update when confirmed', async () => {\n const writeMock = vi.fn().mockResolvedValue(undefined)\n // @ts-ignore\n global.navigator = { clipboard: { writeText: writeMock } } as unknown\n\n const confirmModule = await import('@/composables/useConfirm')\n vi.spyOn(confirmModule, 'showConfirm').mockResolvedValue(true as unknown)\n // Mock the api module for this test to return a new key on regenerate\n vi.doMock('@/services/api', () => ({\n apiService: {\n regenerateApiKey: vi.fn().mockResolvedValue({ apiKey: 'NEWKEY' }),\n generateInitialApiKey: vi.fn(),\n },\n }))\n\n const { default: ApiKeyControl } = await import('@/components/ui/ApiKeyControl.vue')\n const wrapper = mount(ApiKeyControl, {\n props: { apiKey: 'OLDKEY' },\n global: { components: { PasswordInput } },\n })\n\n // Call the internal handler directly to avoid DOM-event quirks in VTU\n const setupState = (wrapper.vm as unknown).$?.setupState || (wrapper.vm as unknown).$setup\n await (setupState.onRegenerate as Function)()\n // wait for async handlers and promise resolution\n await new Promise((r) => setTimeout(r, 0))\n\n // Ensure underlying API was called\n const apiModule = await import('@/services/api')\n\n\n\n\n expect((apiModule.apiService.regenerateApiKey as unknown).mock).toBeTruthy()\n expect((apiModule.apiService.regenerateApiKey as unknown).mock.calls.length).toBeGreaterThan(0)\n\n // Should emit update:apiKey with new key\n expect(wrapper.emitted()['update:apiKey']).toBeTruthy()\n expect(wrapper.emitted()['update:apiKey']![0]).toEqual(['NEWKEY'])\n\n expect(writeMock).toHaveBeenCalledWith('NEWKEY')\n })\n\n it('generates initial key when none exists', async () => {\n const writeMock = vi.fn().mockResolvedValue(undefined)\n // @ts-ignore\n global.navigator = { clipboard: { writeText: writeMock } } as unknown\n\n const confirmModule = await import('@/composables/useConfirm')\n vi.spyOn(confirmModule, 'showConfirm').mockResolvedValue(true as unknown)\n // Mock generateInitialApiKey to return a new key for initial generation\n vi.doMock('@/services/api', () => ({\n apiService: {\n regenerateApiKey: vi.fn(),\n generateInitialApiKey: vi.fn().mockResolvedValue({ apiKey: 'INITKEY' }),\n },\n }))\n\n const { default: ApiKeyControl } = await import('@/components/ui/ApiKeyControl.vue')\n const wrapper = mount(ApiKeyControl, {\n props: { apiKey: '' },\n global: { components: { PasswordInput } },\n })\n\n const regenBtn = wrapper.find('button.regen-btn')\n await regenBtn.trigger('click')\n await new Promise((r) => setTimeout(r, 0))\n\n // Ensure underlying API was called\n const apiModule = await import('@/services/api')\n expect((apiModule.apiService.generateInitialApiKey as unknown).mock).toBeTruthy()\n expect((apiModule.apiService.generateInitialApiKey as unknown).mock.calls.length).toBeGreaterThan(0)\n\n expect(wrapper.emitted()['update:apiKey']).toBeTruthy()\n expect(wrapper.emitted()['update:apiKey']![0]).toEqual(['INITKEY'])\n expect(writeMock).toHaveBeenCalledWith('INITKEY')\n })\n})\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AppActivityBadge.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AudiobookDetailView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AudiobooksView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\AuthenticationSection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\CollectionView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ConfirmModal.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\DownloadClientFormModal.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\DownloadClientsTab.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\DownloadSettingsSection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\EditAudiobookModal.moveOptions.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\EditAudiobookModal.relativePath.spec.ts","messages":[{"ruleId":"vitest/no-conditional-expect","severity":2,"message":"Avoid calling `expect` inside conditional statements","line":48,"column":7,"messageId":"noConditionalExpect","endLine":48,"endColumn":131}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { mount } from '@vue/test-utils'\nimport { vi, describe, it, expect } from 'vitest'\nimport { nextTick } from 'vue'\n\nvi.mock('@/services/api', () => ({\n apiService: {\n getQualityProfiles: vi.fn().mockResolvedValue([]),\n getApplicationSettings: vi.fn().mockResolvedValue({ outputPath: 'C:\\\\root' }),\n getRootFolders: vi\n .fn()\n .mockResolvedValue([{ id: 1, name: 'Default', path: 'C:\\\\root', isDefault: true }]),\n },\n}))\n\nimport EditAudiobookModal from '@/components/domain/audiobook/EditAudiobookModal.vue'\n\nconst audiobook = {\n id: 1,\n title: 'Sample',\n authors: ['Author'],\n basePath: 'C:\\\\root\\\\Some Author\\\\Some Title',\n monitored: true,\n tags: [],\n}\n\ndescribe('EditAudiobookModal relative path calculation', () => {\n it('shows full path in readonly input by default', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Primary assertion: combined path should match expected (normalize slashes)\n expect(((wrapper.vm as unknown).combinedBasePath() || '').replace(/\\\\/g, '/')).toBe('C:/root/Some Author/Some Title')\n\n // If the readonly input exists in this environment, also assert its value\n const readonlyInput = wrapper.find('.readonly-input')\n if (readonlyInput.exists()) {\n expect(((readonlyInput.element as HTMLInputElement).value || '').replace(/\\\\/g, '/')).toBe('C:/root/Some Author/Some Title')\n }\n })\n\n it('derives relative path from stored basePath when root configured', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Expect the internal relativePath to be derived from stored basePath\n expect((wrapper.vm as unknown).formData.relativePath).toBe('Some Author\\\\Some Title')\n })\n\n it('normalizes absolute path to relative when Done is clicked', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Set absolute value and call finishEditingDestination directly\n ;(wrapper.vm as unknown).formData.relativePath = 'C:\\\\root\\\\New Author\\\\New Title'\n await (wrapper.vm as unknown).finishEditingDestination()\n\n // After normalization the internal relativePath should be the short relative\n expect((wrapper.vm as unknown).formData.relativePath).toBe('New Author\\\\New Title')\n })\n\n it('preserves a user-typed relative path after Done and reopen', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Type a relative path and call Done directly\n ;(wrapper.vm as unknown).formData.relativePath = 'My Author\\\\My Title'\n await (wrapper.vm as unknown).finishEditingDestination()\n\n // The internal relativePath should remain what the user typed\n expect((wrapper.vm as unknown).formData.relativePath).toBe('My Author\\\\My Title')\n })\n\n it('prefills absolute path when switching to Custom path', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Simulate switching to Custom path by setting selectedRootId\n ;(wrapper.vm as unknown).selectedRootId = 0\n await nextTick()\n\n // customRootPath should be prefilled to the full base path (normalize slashes)\n expect(((wrapper.vm as unknown).customRootPath || '').replace(/\\\\/g, '/')).toBe('C:/root/Some Author/Some Title')\n })\n\n it('does not duplicate relative part when saving a Custom path', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Simulate selecting Custom path directly\n ;(wrapper.vm as unknown).selectedRootId = 0\n ;(wrapper.vm as unknown).customRootPath = (wrapper.vm as unknown).combinedBasePath()\n await nextTick()\n\n // combinedBasePath should equal the custom path exactly (no duplication)\n const cb = (wrapper.vm as unknown).combinedBasePath()\n const cr = (wrapper.vm as unknown).customRootPath\n expect((cb || '').replace(/\\\\/g, '/')).toBe((cr || '').replace(/\\\\/g, '/'))\n })\n\n it('selects custom path via folder browser and saves exact custom path (no duplication)', async () => {\n const wrapper = mount(EditAudiobookModal, {\n props: {\n isOpen: true,\n audiobook,\n },\n attachTo: document.body,\n global: {\n plugins: [(await import('pinia')).createPinia()],\n },\n })\n\n // allow async init\n await new Promise((r) => setTimeout(r, 10))\n\n // Simulate folder browser selection by setting custom root directly\n ;(wrapper.vm as unknown).selectedRootId = 0\n ;(wrapper.vm as unknown).customRootPath = 'C:\\\\temp\\\\Isaac Asimov\\\\Foundation'\n await nextTick()\n\n // combinedBasePath should equal the selected custom root exactly\n const cb = (wrapper.vm as unknown).combinedBasePath()\n expect(cb.replace(/\\\\/g, '/')).toBe('C:/temp/Isaac Asimov/Foundation')\n })\n})\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ExternalRequestsSection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\FeaturesSection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\FileManagementSection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\IndexerFormModal.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\IndexersTab.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ManualSearchModal.spec.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'setResults' is assigned a value but never used.","line":75,"column":11,"messageId":"unusedVar","endLine":75,"endColumn":21}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { mount } from '@vue/test-utils'\nimport { nextTick } from 'vue'\nimport { describe, it, expect } from 'vitest'\nimport ManualSearchModal from '@/components/domain/search/ManualSearchModal.vue'\n\ntype ManualSearchResult = {\n id: string\n title?: string\n downloadType?: string\n resultUrl?: string\n source?: string\n nzbUrl?: string\n sourceLink?: string\n size?: number\n quality?: string\n format?: string\n language?: string\n}\n\ntype QualityScore = {\n searchResult: ManualSearchResult\n totalScore: number\n scoreBreakdown: Record\n rejectionReasons: string[]\n isRejected: boolean\n smartScore?: number\n smartScoreBreakdown?: Record\n}\n\ntype QualityScoresMap =\n | Map\n | { value?: Map; set?: (k: string, v: QualityScore) => void }\n | Map\n\ndescribe('ManualSearchModal.vue', () => {\n const stubs = {\n PhMagnifyingGlass: true,\n PhX: true,\n PhSpinner: true,\n PhArrowClockwise: true,\n PhArrowUp: true,\n PhArrowDown: true,\n PhXCircle: true,\n PhDownloadSimple: true,\n PhArrowsDownUp: true,\n // Ensure ScorePopover renders its default slot in tests so the inner badge is present\n ScorePopover: { template: '
' },\n Modal: { template: '
' },\n ModalHeader: { template: '
' },\n ModalBody: { template: '
' },\n }\n\n // Helper to set `results` on the component instance in a way that works\n // whether the component exposes a ref (`.value`) or an unwrapped array.\n const setResultsOnVm = (vm: unknown, r: unknown) => {\n if (vm && vm.results && typeof vm.results === 'object' && 'value' in vm.results) {\n vm.results.value = r\n } else if (vm) {\n vm.results = r\n }\n }\n\n it('uses details page for Usenet title links instead of direct NZB', async () => {\n const wrapper = mount(ManualSearchModal, {\n props: { isOpen: true, audiobook: null },\n global: { stubs },\n })\n const vm = wrapper.vm as unknown as {\n results: ManualSearchResult[]\n qualityScores?: QualityScoresMap\n }\n\n // Set a usenet-style result where id is an informational URL that should be used for the title link\n // Support both raw arrays and refs (test runner may expose refs differently)\n const setResults = (r: unknown) => {\n if (vm && (vm as unknown).results && typeof (vm as unknown).results === 'object' && 'value' in (vm as unknown).results) {\n ;(vm as unknown).results.value = r\n } else if (vm) {\n ;(vm as unknown).results = r\n }\n }\n\n setResultsOnVm(vm, [\n {\n id: 'https://indexer/info/123',\n title: 'Test Usenet',\n downloadType: 'Usenet',\n resultUrl: '',\n sourceLink: 'https://indexer/info/123',\n nzbUrl: 'https://indexer/download/123.nzb',\n source: 'altHUB',\n size: 123,\n },\n ])\n\n await nextTick()\n\n // Debug: show rendered HTML to investigate missing anchor\n \n console.log(wrapper.html())\n const anchor = wrapper.find('a.title-text')\n expect(anchor.exists()).toBe(true)\n expect(anchor.attributes('href')).toBe('https://indexer/info/123')\n })\n\n it('does not show language badge when language is Unknown', async () => {\n const wrapper = mount(ManualSearchModal, {\n props: { isOpen: true, audiobook: null },\n global: { stubs },\n })\n const vm = wrapper.vm as unknown as {\n results: ManualSearchResult[]\n qualityScores?: QualityScoresMap\n }\n\n setResultsOnVm(vm, [\n {\n id: 'u2',\n title: 'Lang Test',\n language: 'Unknown',\n downloadType: 'Usenet',\n resultUrl: 'https://indexer/info/2',\n source: 'alt',\n size: 0,\n },\n ])\n\n await nextTick()\n\n const langBadge = wrapper.find('.language-badge')\n expect(langBadge.exists()).toBe(false)\n })\n\n it('does not show duplicate format fallback when format equals quality', async () => {\n const wrapper = mount(ManualSearchModal, {\n props: { isOpen: true, audiobook: null },\n global: { stubs },\n })\n const vm = wrapper.vm as unknown as {\n results: ManualSearchResult[]\n qualityScores?: QualityScoresMap\n }\n\n setResultsOnVm(vm, [\n {\n id: 'q1',\n title: 'Format Fallback Test',\n quality: 'FLAC',\n format: 'FLAC',\n downloadType: 'Torrent',\n resultUrl: 'https://indexer/info/4',\n source: 'test',\n size: 0,\n },\n ])\n\n await nextTick()\n\n const badge = wrapper.find('.col-quality .quality-badge')\n expect(badge.exists()).toBe(true)\n expect(badge.text()).toContain('FLAC')\n // Should not contain duplicate 'FLAC' after the dot\n expect(badge.text()).not.toContain('FLAC · FLAC')\n })\n\n it('shows rejection reason instead of score for rejected results', async () => {\n const wrapper = mount(ManualSearchModal, {\n props: { isOpen: true, audiobook: null },\n global: { stubs },\n })\n const vm = wrapper.vm as unknown as {\n results: ManualSearchResult[]\n qualityScores?: QualityScoresMap\n }\n\n const fake = {\n id: 'r3',\n title: 'Rejected Test',\n downloadType: 'Torrent',\n resultUrl: 'https://indexer/info/3',\n source: 'test',\n size: 0,\n }\n\n setResultsOnVm(vm, [fake])\n\n const scoreObj: QualityScore = {\n searchResult: fake,\n totalScore: -1,\n scoreBreakdown: {},\n rejectionReasons: ['No seeds'],\n isRejected: true,\n }\n\n // Try to set via .value (ref) when available\n if (\n vm.qualityScores &&\n (\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).value &&\n typeof (\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).value!.set === 'function'\n ) {\n ;(\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).value!.set('r3', scoreObj)\n }\n\n // Also set directly on the unwrapped proxy for compatibility with test runner behavior\n if (\n vm.qualityScores &&\n typeof (\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).set === 'function'\n ) {\n ;(\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).set!('r3', scoreObj)\n }\n\n await nextTick()\n\n const badge = wrapper.find('.col-score .score-badge.rejected')\n expect(badge.exists()).toBe(true)\n // Badge should read 'Rejected'\n expect(badge.text()).toContain('Rejected')\n // The title/hover should contain the rejection reason\n expect(badge.attributes('title')).toContain('No seeds')\n })\n\n it('shows Smart total as the score badge when smartScore is present', async () => {\n const wrapper = mount(ManualSearchModal, {\n props: { isOpen: true, audiobook: null },\n global: { stubs },\n })\n const vm = wrapper.vm as unknown as {\n results: ManualSearchResult[]\n qualityScores?: QualityScoresMap\n }\n\n setResultsOnVm(vm, [\n {\n id: 'r1',\n title: 'Smart Score Test',\n downloadType: 'Torrent',\n resultUrl: 'https://indexer/info/1',\n source: 'test',\n size: 0,\n },\n ])\n\n // Provide a quality score with a smartScore. Ensure both ref.value and unwrapped Map get the entry\n const scoreObj: QualityScore = {\n searchResult: vm.results[0],\n totalScore: 47,\n scoreBreakdown: { Quality: 65 },\n rejectionReasons: [],\n isRejected: false,\n smartScore: 12345,\n smartScoreBreakdown: { Quality: 65000 },\n }\n\n // Try to set via .value (ref) when available\n if (\n vm.qualityScores &&\n (\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).value &&\n typeof (\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).value!.set === 'function'\n ) {\n ;(\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).value!.set('r1', scoreObj)\n }\n\n // Also set directly on the unwrapped proxy for compatibility with test runner behavior\n if (\n vm.qualityScores &&\n typeof (\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).set === 'function'\n ) {\n ;(\n vm.qualityScores as unknown as {\n value?: Map\n set?: (k: string, v: QualityScore) => void\n }\n ).set!('r1', scoreObj)\n }\n\n // As a last-resort replace the Map entirely\n // Provide smartScoreBreakdown so the visible total is computed from component averages\n scoreObj.smartScore = 1234.5\n scoreObj.smartScoreBreakdown = { Quality: 90000, Format: 8500, Seed: 2000 }\n vm.qualityScores = new Map([['r1', scoreObj]])\n\n await nextTick()\n\n const badge = wrapper.find('.col-score .score-badge')\n expect(badge.exists()).toBe(true)\n // Normalized components: Quality=90, Format=85, Seed=20 -> avg ~65\n expect(badge.text()).toContain('65')\n })\n})\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ModalForm.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\ModalHeader.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\NotificationsTab.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\PasswordInput.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\QualityProfilesTab.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\RootFoldersSettings.spec.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'store' is assigned a value but never used.","line":12,"column":11,"messageId":"unusedVar","endLine":12,"endColumn":16}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { describe, it, expect, vi } from 'vitest'\nimport { mount } from '@vue/test-utils'\nimport { createPinia, setActivePinia } from 'pinia'\nimport RootFoldersSettings from '@/components/settings/RootFoldersSettings.vue'\nimport { useRootFoldersStore } from '@/stores/rootFolders'\n\ndescribe('RootFoldersSettings', () => {\n it('shows header spinner and loading state when store.loading is true', async () => {\n const pinia = createPinia()\n setActivePinia(pinia)\n\n const store = useRootFoldersStore()\n\n // Make the underlying API call pending so store.loading remains true while mounted\n const api = await import('@/services/api')\n let resolveFn: (value: unknown) => void = () => {}\n // spy on the apiService instance method (module-level named export is not present in TS types)\n vi.spyOn((api as unknown).apiService, 'getRootFolders').mockImplementation(\n () => new Promise((res) => {\n resolveFn = res\n }) as unknown,\n )\n\n const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } })\n // Wait for onMounted to run and for store.load() to set loading=true\n await wrapper.vm.$nextTick()\n\n expect(wrapper.find('.loading-state').exists()).toBe(true)\n expect(wrapper.find('.section-header .small-inline-spinner').exists()).toBe(true)\n\n // Resolve API and ensure UI updates\n resolveFn([])\n await new Promise((r) => setTimeout(r, 0))\n await wrapper.vm.$nextTick()\n })\n})","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\SearchResultActions.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\SearchResultCard.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\SearchResultMetadata.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\SearchSettingsSection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\SettingsView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\WantedView.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\api.csrf-retry.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\api.ensureImageCached.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\audiobook-detailview.signalr.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\audiobook-update-merge.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\customFilterEvaluator.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\debug_AddNew.spec.ts","messages":[{"ruleId":"vitest/no-disabled-tests","severity":1,"message":"Disabled test suite - if you want to skip a test suite temporarily, use .todo() instead","line":4,"column":10,"messageId":"disabledSuite","endLine":4,"endColumn":14}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { describe, it, expect } from 'vitest'\n\n// placeholder debug spec — intentionally skipped in CI\ndescribe.skip('debug AddNew placeholder', () => {\n it('placeholder', () => {\n expect(true).toBe(true)\n })\n})","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\grabsSortable.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\import-activity.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\library.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\sanity.js","messages":[{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":1,"column":34,"messageId":"noRequireImports","endLine":1,"endColumn":51}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"const { describe, it, expect } = require('vitest')\n\ndescribe('sanity js', () => {\n it('runs a basic test', () => {\n expect(1 + 1).toBe(2)\n })\n})\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\sanity.spec.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\sanity.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\searchResultFormatting.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\searchResultHelpers.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\sessionTokenStorage.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\startupConfigCache.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\test-setup.ts","messages":[{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":356,"column":17,"messageId":"noRequireImports","endLine":356,"endColumn":30}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":" \n// Test setup: Polyfill / mock environment pieces that tests expect\n// - Provide a Mock WebSocket implementation so SignalR code can run in jsdom\n\nclass MockWebSocket {\n static OPEN = 1\n public readyState = MockWebSocket.OPEN\n public onopen: (() => void) | null = null\n public onmessage: ((ev: { data: string }) => void) | null = null\n public onerror: ((err: Error) => void) | null = null\n public onclose: (() => void) | null = null\n private url: string\n constructor(url: string) {\n this.url = url\n // simulate async open\n setTimeout(() => {\n if (this.onopen) this.onopen()\n }, 0)\n }\n send(_data: string) {\n // Reference the arg so linters don't complain about unused params in tests\n void _data\n /* no-op in tests */\n }\n close() {\n if (this.onclose) this.onclose()\n }\n}\n\n// Centralized apiService and signalR mocks used by unit tests.\nimport { vi } from 'vitest'\n\n// Diagnostic: help locate failures during test setup in CI/local runs\ntry {\n \n console.log('[test-setup] initializing test setup')\n} catch {}\n\n// Provide default component stubs for Modal teleporting components so unit tests\n// render modal content inline instead of using real teleport behavior.\nimport { config as vtConfig } from '@vue/test-utils'\nconst globalConfig = ((vtConfig.global ??= {} as unknown) as unknown)\nglobalConfig.components = {\n ...(globalConfig.components || {}),\n // Render modal content inline with accessible dialog attributes so tests\n // can query for role=\"dialog\" and aria-* attributes reliably.\n Modal: {\n template:\n '
',\n },\n ModalHeader: { template: '
' },\n ModalBody: { template: '
' },\n\n // Provide lightweight test stubs for commonly used components so unit tests\n // don't fail on missing component resolution for icon or small base pieces.\n LoadingState: {\n props: ['message', 'size'],\n template: '

{{ message }}

',\n },\n PhSpinner: {\n props: ['size'],\n template: '',\n },\n // Stub the BrandLogo component so tests don't trigger static-asset resolution\n BrandLogo: {\n template: '
',\n },\n}\n\n// Also mock the BrandLogo module at import-time so Vite doesn't compile the real\n// SFC (which would try to resolve `/logo.svg` at build/transform time and can\n// cause file:/// URL issues in the test runner).\nvi.mock('@/components/base/BrandLogo.vue', () => ({\n default: {\n template: '
',\n },\n}))\n\n// Some components import the modal pieces locally (via named imports). To ensure\n// tests always render the simplified accessible modal markup (and avoid teleport\n// behavior), partially mock the feedback module so SFC-local imports receive the\n// inline stubs while preserving other named exports from the real module.\nvi.mock('@/components/feedback', async (importOriginal) => {\n const actual = (await importOriginal()) as Record\n const modalStub: unknown = {\n emits: ['close'],\n props: ['visible', 'title', 'showClose', 'size'],\n template:\n '
',\n mounted() {\n this._onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') this.$emit?.('close')\n }\n document.addEventListener('keydown', this._onKey)\n },\n unmounted() {\n if (this._onKey) document.removeEventListener('keydown', this._onKey)\n },\n }\n return {\n ...actual,\n Modal: modalStub,\n ModalHeader: {\n props: ['title', 'icon', 'iconLabel'],\n emits: ['close'],\n template:\n '

{{title}}

',\n },\n ModalBody: { template: '
' },\n ModalFooter: { template: '
' },\n }\n})\n\n// Provide both the `apiService` object and common named exports that components\n// import directly (e.g. `getRemotePathMappings`, `ensureImageCached`). Tests\n// expect these named exports to exist on the mocked module.\nvi.mock('@/services/api', () => {\n const apiService = {\n searchAudimetaByTitleAndAuthor: vi.fn(async () => ({ totalResults: 0, results: [] })),\n advancedSearch: async (params: unknown) => {\n const p = params as { title?: string; author?: string } | undefined\n if (p?.title) {\n const mod = await import('@/services/api')\n const svc = mod.apiService as unknown as {\n searchAudimetaByTitleAndAuthor?: (\n title: string,\n author?: string,\n ) => Promise<{ totalResults?: number; results?: unknown[] } | unknown>\n }\n if (svc.searchAudimetaByTitleAndAuthor) {\n const resp = (await svc.searchAudimetaByTitleAndAuthor(p.title, p.author)) as unknown\n const r = resp as unknown\n return (r?.results) || r || []\n }\n return []\n }\n return { totalResults: 0, results: [] }\n },\n getImageUrl: vi.fn((url: string) => url || ''),\n getStartupConfig: vi.fn(async () => ({})),\n getApplicationSettings: vi.fn(async () => ({})),\n getLibrary: vi.fn(async () => []),\n previewLibraryPath: vi.fn(async () => ({ path: '' })),\n getQualityProfiles: vi.fn(async () => []),\n getApiConfigurations: vi.fn(async () => []),\n // add getRootFolders to apiService so tests that spy on apiService.getRootFolders work\n getRootFolders: vi.fn(async () => []),\n\n // add checkVolume to apiService so components that call `apiService.checkVolume` in\n // unit tests have a sensible default value that matches the real API signature.\n // Default behaviour: treat paths on the same root as sameVolume and do not break\n // hardlinks.\n checkVolume: vi.fn(async (sourcePath: string, destPath: string) => {\n const same =\n typeof sourcePath === 'string' &&\n typeof destPath === 'string' &&\n // simple heuristic: same leading path segment or same drive letter on Windows\n (sourcePath.split(/[\\\\/]/)[1] === destPath.split(/[\\\\/]/)[1] ||\n (/^[A-Za-z]:/.test(sourcePath) && sourcePath[0].toLowerCase() === destPath[0]?.toLowerCase()))\n return {\n sameVolume: Boolean(same),\n willBreakHardlinks: !same,\n sourceVolume: typeof sourcePath === 'string' ? sourcePath.split(/[\\\\/]/)[1] || '' : undefined,\n destVolume: typeof destPath === 'string' ? destPath.split(/[\\\\/]/)[1] || '' : undefined,\n message: same ? 'Same volume' : 'Different volumes',\n }\n }),\n }\n\n // Named exports commonly imported by components/tests\n return {\n apiService,\n // Path/remote helpers\n getRemotePathMappings: vi.fn(async () => []),\n testDownloadClient: vi.fn(async () => ({ success: true })),\n\n // Image helpers\n ensureImageCached: vi.fn(async (url: string) => url || ''),\n\n // Logs / files\n getLogs: vi.fn(async () => []),\n downloadLogs: vi.fn(async () => null),\n\n // Root folders / profiles\n getRootFolders: vi.fn(async () => []),\n getQualityProfiles: vi.fn(async () => []),\n\n // Keep the startup / app settings helpers available as named exports too\n getStartupConfig: vi.fn(async () => ({})),\n getApplicationSettings: vi.fn(async () => ({})),\n\n // Expose checkVolume as a named export as well (delegates to the apiService\n // mock above) so tests that import the function directly behave the same.\n checkVolume: vi.fn(async (sourcePath: string, destPath: string) =>\n (apiService as unknown).checkVolume(sourcePath, destPath),\n ),\n }\n})\n\nvi.mock('@/services/signalr', () => ({\n signalRService: {\n connect: () => {},\n onDownloadsList: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onSearchProgress: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onQueueUpdate: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onDownloadUpdate: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onFilesRemoved: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onAudiobookUpdate: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onNotification: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n onToast: (cb?: (...args: unknown[]) => void) => {\n void cb\n return () => {}\n },\n },\n}))\n\n// Ensure global WebSocket exists for code that references it\nif (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') {\n ;(globalThis as unknown as { WebSocket?: unknown }).WebSocket = MockWebSocket\n}\n\n// Also provide a minimal window.WebSocket for code referencing window\nif (typeof (window as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') {\n ;(window as unknown as { WebSocket?: unknown }).WebSocket = MockWebSocket\n}\n\n// Provide a noop for console.debug in tests where code wraps in try/catch\nif (typeof console.debug !== 'function') console.debug = console.log.bind(console)\n\n// Ensure JSDOM's base URL is HTTP (not file://) so absolute static asset paths\n// (e.g. `/logo.svg`) resolve to `http://localhost/...` instead of `file:///...`.\n// On Windows the `file:///` form can surface in source-maps and cause Node APIs\n// to reject the path; setting the location prevents those file URLs from\n// appearing during transforms and stacktrace processing.\ntry {\n if (typeof window !== 'undefined' && window.location && window.location.href.startsWith('file:')) {\n // Replace file://... base with http://localhost/\n window.history.replaceState({}, '', 'http://localhost/')\n }\n // Also ensure document.baseURI is an HTTP URL (some code consults baseURI directly)\n if (typeof document !== 'undefined' && document.baseURI && document.baseURI.startsWith('file:')) {\n try {\n Object.defineProperty(document, 'baseURI', { value: 'http://localhost/', configurable: true })\n } catch {}\n }\n} catch {}\n\n// Provide a simple localStorage polyfill for tests that rely on it\n// Ensure a working localStorage implementation exists for tests. Some test\n// runners may set a placeholder object; normalize it so .setItem/.getItem exist.\nif (\n typeof (globalThis as unknown as { localStorage?: { setItem?: unknown } }).localStorage ===\n 'undefined' ||\n typeof (globalThis as unknown as { localStorage?: { setItem?: unknown } }).localStorage\n ?.setItem !== 'function'\n) {\n ;(\n globalThis as unknown as {\n localStorage?: {\n _store?: Record\n getItem?: (k: string) => string | null\n setItem?: (k: string, v: string) => void\n removeItem?: (k: string) => void\n clear?: () => void\n }\n }\n ).localStorage = {\n _store: {} as Record,\n getItem(key: string) {\n return this._store[key] ?? null\n },\n setItem(key: string, value: string) {\n this._store[key] = value + ''\n },\n removeItem(key: string) {\n delete this._store[key]\n },\n clear() {\n this._store = {}\n },\n }\n}\n\n// Defensive: JSDOM / Vitest may encounter `file://` asset URLs (e.g. transformed\n// static asset paths like `file:///logo.svg`). Some environments propagate\n// those to HTMLImageElement.src setters which can trigger Node internal URL/path\n// handling and cause tests to crash. Normalize `file://` image URLs to plain\n// absolute paths to avoid runtime errors during tests.\ntry {\n const imgProto = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')\n Object.defineProperty(HTMLImageElement.prototype, 'src', {\n set(this: HTMLImageElement, value: string) {\n try {\n if (typeof value === 'string' && value.startsWith('file:///')) {\n // Convert file URL (file:///logo.svg) to a usable pathname (/logo.svg)\n const u = new URL(value)\n return imgProto?.set?.call(this, u.pathname)\n }\n } catch {\n // fall through to default setter\n }\n return imgProto?.set?.call(this, value)\n },\n get(this: HTMLImageElement) {\n return imgProto?.get?.call(this)\n },\n configurable: true,\n })\n\n // Some code sets src via setAttribute('src', ...) which bypasses the property\n // setter. Intercept attribute assignments and normalize file:// URLs for src.\n const origSetAttr = Element.prototype.setAttribute\n Element.prototype.setAttribute = function (name: string, value: string) {\n try {\n if (\n typeof name === 'string' &&\n name.toLowerCase() === 'src' &&\n typeof value === 'string' &&\n value.startsWith('file:///')\n ) {\n const u = new URL(value)\n return origSetAttr.call(this, name, u.pathname)\n }\n } catch {}\n return origSetAttr.call(this, name, value)\n }\n} catch {}\n\n// Defensive: some test runners / source-map consumers may attempt to read 'file:///logo.svg'\n // which can throw on Windows / Node file APIs. Normalize any `file:///...` paths to an\n // absolute pathname or return an empty string so tests don't crash during stacktrace\n // or source-map processing.\n try {\n \n const _fs = require('fs') as typeof import('fs')\n const _origRead = _fs.readFile.bind(_fs)\n const _origReadSync = _fs.readFileSync.bind(_fs)\n const _origExistsSync = _fs.existsSync.bind(_fs)\n const _origStatSync = _fs.statSync.bind(_fs)\n const _origRealpathSync = _fs.realpathSync.bind(_fs)\n const _origCreateReadStream = _fs.createReadStream.bind(_fs)\n const _origOpenSync = _fs.openSync ? _fs.openSync.bind(_fs) : undefined\n const _origPromisesRead = _fs.promises && _fs.promises.readFile ? _fs.promises.readFile.bind(_fs.promises) : undefined\n\n function normalizePathArg(p: unknown) {\n try {\n if (typeof p === 'string' && p.startsWith('file:///')) {\n // Convert 'file:///logo.svg' -> '/logo.svg' (safe for test runtime)\n return new URL(p).pathname\n }\n } catch {}\n return p\n }\n\n function isProblematicLogoPath(p: unknown) {\n const np = typeof p === 'string' ? p : ''\n return np === 'file:///logo.svg' || np.endsWith('/logo.svg')\n }\n\n ;(_fs as unknown).readFile = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) {\n const cb = args[args.length - 1]\n if (typeof cb === 'function') return cb(null, '')\n return Promise.resolve('')\n }\n return _origRead(path, ...args)\n }\n\n ;(_fs as unknown).readFileSync = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return ''\n return _origReadSync(path, ...args)\n }\n\n if (_origPromisesRead) {\n ;(_fs as unknown).promises.readFile = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return Promise.resolve('')\n return _origPromisesRead(path, ...args)\n }\n }\n\n ;(_fs as unknown).existsSync = function (p: unknown) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return true\n return _origExistsSync(path)\n }\n\n ;(_fs as unknown).statSync = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return { isFile: () => true, isDirectory: () => false } as unknown\n return _origStatSync(path, ...args)\n }\n\n ;(_fs as unknown).realpathSync = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return path\n return _origRealpathSync(path, ...args)\n }\n\n ;(_fs as unknown).createReadStream = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return _origCreateReadStream('/dev/null')\n return _origCreateReadStream(path, ...args)\n }\n\n if (_origOpenSync) {\n ;(_fs as unknown).openSync = function (p: unknown, ...args: unknown[]) {\n const path = normalizePathArg(p)\n if (isProblematicLogoPath(p)) return _origOpenSync(path)\n return _origOpenSync(path, ...args)\n }\n }\n } catch {}\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\textUtils.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\useAdvancedSearch.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\useScore.rejection.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\useScore.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\__tests__\\utils\\path.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\BrandLogo.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\ConfigCard.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\EmptyState.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\FormField.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\InfoCard.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\LoadingState.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\Pill.vue","messages":[{"ruleId":"vue/multi-word-component-names","severity":2,"message":"Component name \"Pill\" should always be multi-word.","line":1,"column":1,"messageId":"unexpected"},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'Component' is defined but never used.","line":2,"column":15,"messageId":"unusedVar","endLine":2,"endColumn":24,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"Component"},"fix":{"range":[25,62],"text":""},"desc":"Remove unused import declaration."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\ProgressBar.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\StatusBadge.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\StatusCard.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\base\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\audiobook\\AddLibraryModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'retryImage' is defined but never used.","line":531,"column":10,"messageId":"unusedVar","endLine":531,"endColumn":20}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\audiobook\\AudiobookDetailsModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'apiService' is defined but never used.","line":177,"column":30,"messageId":"unusedVar","endLine":177,"endColumn":40,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"apiService"},"fix":{"range":[7064,7076],"text":""},"desc":"Remove unused variable \"apiService\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhCheck' is defined but never used.","line":180,"column":32,"messageId":"unusedVar","endLine":180,"endColumn":39,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhCheck"},"fix":{"range":[7238,7247],"text":""},"desc":"Remove unused variable \"PhCheck\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhPlus' is defined but never used.","line":180,"column":41,"messageId":"unusedVar","endLine":180,"endColumn":47,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhPlus"},"fix":{"range":[7247,7255],"text":""},"desc":"Remove unused variable \"PhPlus\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'isAdded' is assigned a value but never used.","line":208,"column":7,"messageId":"unusedVar","endLine":208,"endColumn":14},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'addToLibrary' is assigned a value but never used.","line":367,"column":7,"messageId":"unusedVar","endLine":367,"endColumn":19}],"suppressedMessages":[],"errorCount":5,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\audiobook\\AudiobookModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\audiobook\\EditAudiobookModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\audiobook\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\collection\\BulkEditModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\collection\\CustomFilterModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\collection\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\download\\DownloadClientFormModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\download\\InspectTorrentModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\download\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\search\\AdvancedSearchModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":116,"column":3,"messageId":"unusedVar","endLine":116,"endColumn":6,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[3491,3498],"text":""},"desc":"Remove unused variable \"PhX\"."}]}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\search\\ManualSearchModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":248,"column":3,"messageId":"unusedVar","endLine":248,"endColumn":6,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[11010,11017],"text":""},"desc":"Remove unused variable \"PhX\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'getResultLink' is defined but never used.","line":807,"column":10,"messageId":"unusedVar","endLine":807,"endColumn":23}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\domain\\search\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ConfirmDialog.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":18,"column":7,"messageId":"unusedVar","endLine":18,"endColumn":12}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ConfirmModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\DeleteConfirmationModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":24,"column":36,"messageId":"unusedVar","endLine":24,"endColumn":39,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[805,810],"text":""},"desc":"Remove unused variable \"PhX\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":25,"column":7,"messageId":"unusedVar","endLine":25,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'emit' is assigned a value but never used.","line":30,"column":7,"messageId":"unusedVar","endLine":30,"endColumn":11}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\FolderBrowserModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ManualImportModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'openMatchDialog' is assigned a value but never used.","line":629,"column":7,"messageId":"unusedVar","endLine":629,"endColumn":22},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'toggleSelectAll' is assigned a value but never used.","line":661,"column":7,"messageId":"unusedVar","endLine":661,"endColumn":22}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\Modal.vue","messages":[{"ruleId":"vue/multi-word-component-names","severity":2,"message":"Component name \"Modal\" should always be multi-word.","line":1,"column":1,"messageId":"unexpected"}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ModalActions.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ModalBody.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ModalFooter.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":45,"column":7,"messageId":"unusedVar","endLine":45,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'emit' is assigned a value but never used.","line":58,"column":7,"messageId":"unusedVar","endLine":58,"endColumn":11}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ModalForm.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":9,"column":7,"messageId":"unusedVar","endLine":9,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'submit' is assigned a value but never used.","line":21,"column":7,"messageId":"unusedVar","endLine":21,"endColumn":13}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ModalHeader.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'ref' is defined but never used.","line":26,"column":10,"messageId":"unusedVar","endLine":26,"endColumn":13,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"ref"},"fix":{"range":[822,826],"text":""},"desc":"Remove unused variable \"ref\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'emit' is assigned a value but never used.","line":38,"column":7,"messageId":"unusedVar","endLine":38,"endColumn":11}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\ModalSpinnerOverlay.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":12,"column":7,"messageId":"unusedVar","endLine":12,"endColumn":12}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\MoveAudiobookModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\NotificationModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":29,"column":7,"messageId":"unusedVar","endLine":29,"endColumn":12}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\RemotePathMappingModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhDesktop' is defined but never used.","line":86,"column":32,"messageId":"unusedVar","endLine":86,"endColumn":41,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhDesktop"},"fix":{"range":[3158,3169],"text":""},"desc":"Remove unused variable \"PhDesktop\"."}]}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\feedback\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\form\\ApiKeyControl.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\form\\Checkbox.vue","messages":[{"ruleId":"vue/multi-word-component-names","severity":2,"message":"Component name \"Checkbox\" should always be multi-word.","line":1,"column":1,"messageId":"unexpected"},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":11,"column":7,"messageId":"unusedVar","endLine":11,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'emit' is assigned a value but never used.","line":12,"column":7,"messageId":"unusedVar","endLine":12,"endColumn":11}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\form\\CustomSelect.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\form\\PasswordInput.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":28,"column":7,"messageId":"unusedVar","endLine":28,"endColumn":12}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\form\\RootFolderSelect.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'ref' is defined but never used.","line":25,"column":10,"messageId":"unusedVar","endLine":25,"endColumn":13,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"ref"},"fix":{"range":[784,788],"text":""},"desc":"Remove unused variable \"ref\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhInfo' is defined but never used.","line":27,"column":21,"messageId":"unusedVar","endLine":27,"endColumn":27,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhInfo"},"fix":{"range":[906,914],"text":""},"desc":"Remove unused variable \"PhInfo\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'isCustom' is assigned a value but never used.","line":47,"column":7,"messageId":"unusedVar","endLine":47,"endColumn":15}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\form\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\search\\SearchResultActions.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\search\\SearchResultCard.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\search\\SearchResultMetadata.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\search\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\AuthenticationSection.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\CheckboxCard.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":18,"column":7,"messageId":"unusedVar","endLine":18,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'emit' is assigned a value but never used.","line":19,"column":7,"messageId":"unusedVar","endLine":19,"endColumn":11}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\DownloadSettingsSection.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\ExternalRequestsSection.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":17,"column":7,"messageId":"unusedVar","endLine":17,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'emit' is assigned a value but never used.","line":18,"column":7,"messageId":"unusedVar","endLine":18,"endColumn":11}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\FeaturesSection.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\FileManagementSection.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\FormRow.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\FormSection.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\IndexerFormModal.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":216,"column":10,"messageId":"unusedVar","endLine":216,"endColumn":13,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[9428,9432],"text":""},"desc":"Remove unused variable \"PhX\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhSpinner' is defined but never used.","line":216,"column":24,"messageId":"unusedVar","endLine":216,"endColumn":33,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhSpinner"},"fix":{"range":[9440,9451],"text":""},"desc":"Remove unused variable \"PhSpinner\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhCheck' is defined but never used.","line":216,"column":43,"messageId":"unusedVar","endLine":216,"endColumn":50,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhCheck"},"fix":{"range":[9459,9468],"text":""},"desc":"Remove unused variable \"PhCheck\"."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\QualityProfileFormModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\RadioCard.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\RemotePathMappingForm.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\RemotePathMappingsManager.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\RootFolderFormModal.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\RootFoldersSettings.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhPlus' is defined but never used.","line":106,"column":3,"messageId":"unusedVar","endLine":106,"endColumn":9,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhPlus"},"fix":{"range":[3555,3562],"text":""},"desc":"Remove unused variable \"PhPlus\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhWarningCircle' is defined but never used.","line":112,"column":3,"messageId":"unusedVar","endLine":112,"endColumn":18,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhWarningCircle"},"fix":{"range":[3625,3644],"text":""},"desc":"Remove unused variable \"PhWarningCircle\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":113,"column":3,"messageId":"unusedVar","endLine":113,"endColumn":6,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[3644,3651],"text":""},"desc":"Remove unused variable \"PhX\"."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\SearchSettingsSection.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\settings\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\ApiKeyControl.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\FiltersDropdown.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\FolderBrowser.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\GlobalToast.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\Icon.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\ScorePopover.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'props' is assigned a value but never used.","line":10,"column":7,"messageId":"unusedVar","endLine":10,"endColumn":12}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\components\\ui\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\confirmService.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useAdvancedSearch.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useConfirm.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useFormState.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useLibraryCheck.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useLoadingState.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useNotification.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useProtectedImages.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useScore.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useSearch.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useSignalR.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\composables\\useSystemLogs.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\main.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\router\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\amazon.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\api.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\apiBase.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\audnexus.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\errorTracking.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\isbn.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\openlibrary.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\openlibraryMap.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\signalr.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\signalrEvents.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\startupConfigCache.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\services\\toastService.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\auth.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\configuration.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\counter.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\downloads.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'currentDownloadIds' is assigned a value but never used.","line":88,"column":13,"messageId":"unusedVar","endLine":88,"endColumn":31}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { defineStore } from 'pinia'\nimport { ref, computed, shallowRef, triggerRef } from 'vue'\nimport type { Download, SearchResult, Audiobook } from '@/types'\nimport { apiService } from '@/services/api'\nimport { useLibraryStore } from '@/stores/library'\nimport { signalRService } from '@/services/signalr'\nimport { errorTracking } from '@/services/errorTracking'\nimport { logger } from '@/utils/logger'\n\nexport const useDownloadsStore = defineStore('downloads', () => {\n const downloads = shallowRef([])\n const isLoading = ref(false)\n let unsubscribeUpdate: (() => void) | null = null\n let unsubscribeList: (() => void) | null = null\n let unsubscribeQueue: (() => void) | null = null\n let unsubscribeAudiobook: (() => void) | null = null\n\n // Subscribe to SignalR updates\n const initializeSignalR = () => {\n // Subscribe to individual download updates\n unsubscribeUpdate = signalRService.onDownloadUpdate(async (updatedDownloads) => {\n updatedDownloads.forEach((updated) => {\n const index = downloads.value.findIndex((d) => d.id === updated.id)\n if (index !== -1) {\n // Update existing download\n downloads.value[index] = updated\n } else {\n // Add new download\n downloads.value.unshift(updated)\n }\n })\n triggerRef(downloads)\n\n // If any updated downloads are completed and linked to an audiobook, refresh that audiobook in the library store\n // Refresh linked audiobooks so UI shows newly-created files. Use Promise.all to avoid unbounded concurrency.\n const libraryStore = useLibraryStore()\n const tasks: Promise[] = []\n for (const updated of updatedDownloads) {\n const status = (updated.status || '').toString().toLowerCase()\n if (\n (status === 'completed' || status === 'ready') &&\n typeof updated.audiobookId === 'number'\n ) {\n const aid = updated.audiobookId as number\n tasks.push(\n (async () => {\n try {\n const latest = await apiService.getAudiobook(aid)\n // Find existing index\n const idx = libraryStore.audiobooks.findIndex((b) => b.id === latest.id)\n if (idx !== -1) {\n // Preserve the original object reference so components bound to it update reactively\n const target = libraryStore.audiobooks[idx]!\n Object.assign(target, latest)\n } else {\n libraryStore.audiobooks.unshift(latest)\n }\n } catch (e) {\n logger.warn(\n '[Downloads Store] Failed to refresh audiobook after download update',\n e,\n )\n }\n })(),\n )\n }\n }\n if (tasks.length > 0) await Promise.all(tasks)\n\n // Sort by start date\n downloads.value.sort(\n (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),\n )\n triggerRef(downloads)\n })\n\n // Subscribe to full downloads list\n unsubscribeList = signalRService.onDownloadsList((downloadsList) => {\n downloads.value = downloadsList\n triggerRef(downloads)\n })\n\n // Subscribe to queue updates (replacement list from backend)\n unsubscribeQueue = signalRService.onQueueUpdate((queueItems) => {\n // QueueUpdate provides the current queue state\n // When a download is completed and removed, it won't be in this list\n // We need to update our downloads to match the queue\n const currentDownloadIds = new Set(downloads.value.map(d => d.id))\n const queueIds = new Set(queueItems.map(q => q.id))\n \n // Remove downloads that are no longer in the queue\n downloads.value = downloads.value.filter(d => queueIds.has(d.id))\n \n // Update existing and add new items from queue\n queueItems.forEach((queueItem) => {\n const existingIndex = downloads.value.findIndex(d => d.id === queueItem.id)\n \n // Map QueueItem to Download format\n const downloadItem: Download = {\n id: queueItem.id,\n title: queueItem.title || 'Unknown',\n artist: queueItem.author || '',\n album: queueItem.series || '',\n originalUrl: '',\n status: queueItem.status as unknown,\n progress: queueItem.progress || 0,\n totalSize: queueItem.size || 0,\n downloadedSize: queueItem.downloaded || 0,\n downloadPath: queueItem.remotePath || '',\n finalPath: queueItem.localPath || '',\n startedAt: queueItem.addedAt,\n completedAt: undefined,\n errorMessage: queueItem.errorMessage,\n downloadClientId: queueItem.downloadClientId,\n metadata: {},\n }\n \n if (existingIndex !== -1) {\n downloads.value[existingIndex] = downloadItem\n } else {\n downloads.value.push(downloadItem)\n }\n })\n \n triggerRef(downloads)\n })\n\n // Subscribe to AudiobookUpdate messages so we can apply updated audiobook (with Files)\n unsubscribeAudiobook = signalRService.onAudiobookUpdate((updatedAudiobook) => {\n try {\n const libraryStore = useLibraryStore()\n const idx = libraryStore.audiobooks.findIndex((b) => b.id === updatedAudiobook.id)\n if (idx !== -1) {\n const target = libraryStore.audiobooks[idx] as Audiobook\n Object.assign(target, updatedAudiobook)\n } else {\n libraryStore.audiobooks.unshift(updatedAudiobook)\n }\n } catch (e) {\n logger.warn('[Downloads Store] Failed to apply AudiobookUpdate', e)\n }\n })\n // audiobook unsubscribe will be cleaned up in the common cleanup below\n }\n\n // Initialize SignalR connection\n initializeSignalR()\n\n const activeDownloads = computed(() => {\n const active = downloads.value.filter((d) => {\n const status = (d.status || '').toString().toLowerCase()\n const isActive = ['queued', 'downloading', 'paused', 'processing'].includes(status)\n return isActive\n })\n return active\n })\n\n const completedDownloads = computed(() =>\n downloads.value.filter((d) => {\n const status = (d.status || '').toString().toLowerCase()\n return status === 'completed' || status === 'ready'\n }),\n )\n\n const failedDownloads = computed(() =>\n downloads.value.filter((d) => (d.status || '').toString().toLowerCase() === 'failed'),\n )\n\n const loadDownloads = async () => {\n isLoading.value = true\n try {\n const downloadList = await apiService.getDownloads()\n downloads.value = downloadList\n triggerRef(downloads)\n } catch (error) {\n errorTracking.captureException(error as Error, {\n component: 'DownloadsStore',\n operation: 'loadDownloads',\n })\n } finally {\n isLoading.value = false\n }\n }\n\n const startDownload = async (searchResult: SearchResult, downloadClientId: string) => {\n try {\n const downloadId = await apiService.startDownload(searchResult, downloadClientId)\n // No need to manually refresh - SignalR will push the update\n return downloadId\n } catch (error) {\n errorTracking.captureException(error as Error, {\n component: 'DownloadsStore',\n operation: 'startDownload',\n metadata: { title: searchResult.title, downloadClientId },\n })\n throw error\n }\n }\n\n const cancelDownload = async (downloadId: string) => {\n try {\n await apiService.cancelDownload(downloadId)\n // No need to manually refresh - SignalR will push the update\n } catch (error) {\n errorTracking.captureException(error as Error, {\n component: 'DownloadsStore',\n operation: 'cancelDownload',\n metadata: { downloadId },\n })\n throw error\n }\n }\n\n const updateDownload = (updatedDownload: Download) => {\n const index = downloads.value.findIndex((d) => d.id === updatedDownload.id)\n if (index !== -1) {\n downloads.value[index] = updatedDownload\n }\n }\n\n // Cleanup on store destruction\n const cleanup = () => {\n if (unsubscribeUpdate) unsubscribeUpdate()\n if (unsubscribeList) unsubscribeList()\n if (unsubscribeQueue) unsubscribeQueue()\n if (unsubscribeAudiobook) unsubscribeAudiobook()\n }\n\n return {\n downloads,\n isLoading,\n activeDownloads,\n completedDownloads,\n failedDownloads,\n loadDownloads,\n startDownload,\n cancelDownload,\n updateDownload,\n cleanup,\n }\n})\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\library.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\rootFolders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\stores\\search.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\types\\index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\customFilterEvaluator.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\filterEvaluator.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\imageFallback.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\languageMapping.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\lazyLoad.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\logger.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\marketDomains.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\path.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'err' is defined but never used.","line":53,"column":12,"messageId":"unusedVar","endLine":53,"endColumn":15}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Small path utility helpers used across UI components.\n * Keep these functions small and dependency-free so they are easy to reason about\n * and simple to unit test if needed.\n */\n\nexport function toForward(s: string | null | undefined): string {\n return (s || '').replace(/\\\\/g, '/')\n}\n\nexport function trimTrailingSlash(s: string): string {\n let out = s\n while (out.endsWith('/') || out.endsWith('\\\\')) out = out.slice(0, -1)\n return out\n}\n\nexport function normalizeForCompare(s: string | null | undefined): string {\n return toForward(trimTrailingSlash(s || '')).toLowerCase()\n}\n\nexport function isAbsolutePath(s: string): boolean {\n return /^([a-zA-Z]:[\\\\/]|[\\\\/])/.test(s)\n}\n\n/**\n * If `value` contains the configured `root` prefix, remove it and return the\n * relative portion (respecting backslash style). Returns null if no match.\n */\nexport function stripRootPrefix(root: string, value: string): string | null {\n if (!root || !value) return null\n try {\n const nroot = toForward(root).toLowerCase()\n const nval = toForward(value).toLowerCase()\n\n if (nval.includes(nroot)) {\n const idx = nval.indexOf(nroot)\n const rel = toForward(value).slice(idx + nroot.length).replace(/^\\/+/, '')\n const useBackslash = root.includes('\\\\')\n return useBackslash ? rel.replace(/\\//g, '\\\\') : rel\n }\n\n // fallback: try matching two-segment windows from the end toward the start\n const segs = nroot.split('/')\n for (let i = Math.max(0, segs.length - 2); i >= 0; i--) {\n const two = segs.slice(i, i + 2).join('/')\n if (two && nval.includes(two)) {\n const idx = nval.indexOf(two)\n const rel = toForward(value).slice(idx + two.length).replace(/^\\/+/, '')\n const useBackslash = root.includes('\\\\')\n return useBackslash ? rel.replace(/\\//g, '\\\\\\\\') : rel\n }\n }\n } catch (err) {\n // noop — fall through to null\n }\n\n return null\n}\n\nexport function joinPaths(root: string | null | undefined, relative: string | null | undefined): string {\n if (!root) return relative || ''\n const useBackslash = (root || '').includes('\\\\')\n const r = trimTrailingSlash(toForward(root || ''))\n const rel = (relative || '').toString().replace(/^\\/+/, '')\n const combined = rel ? `${r}/${rel}` : r\n return useBackslash ? combined.replace(/\\//g, '\\\\') : combined\n}\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\placeholder.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\redirect.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\searchResultFormatting.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\searchResultHelpers.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\securityWarningBannerPreference.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\sessionDebug.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\sessionToken.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\utils\\textUtils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\ActivityView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\SettingsView.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'editApiConfig' is assigned a value but never used.","line":635,"column":7,"messageId":"unusedVar","endLine":635,"endColumn":20},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'confirmDeleteApi' is assigned a value but never used.","line":665,"column":7,"messageId":"unusedVar","endLine":665,"endColumn":23},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'toggleApiConfig' is assigned a value but never used.","line":693,"column":7,"messageId":"unusedVar","endLine":693,"endColumn":22},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'testNotification' is assigned a value but never used.","line":714,"column":7,"messageId":"unusedVar","endLine":714,"endColumn":23},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'getWebhookIcon' is assigned a value but never used.","line":953,"column":7,"messageId":"unusedVar","endLine":953,"endColumn":21}],"suppressedMessages":[],"errorCount":5,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\activity\\ActivityView.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'getProgressClass' is assigned a value but never used.","line":596,"column":7,"messageId":"unusedVar","endLine":596,"endColumn":23}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\activity\\DownloadsView.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'download' is defined but never used.","line":275,"column":30,"messageId":"unusedVar","endLine":275,"endColumn":38}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\activity\\LogsView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\auth\\LoginView.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":119,"column":22,"messageId":"unusedVar","endLine":119,"endColumn":23}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\content\\AddNewView.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'extractNarrators' is defined but never used.","line":915,"column":3,"messageId":"unusedVar","endLine":915,"endColumn":19,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"extractNarrators"},"fix":{"range":[35839,35859],"text":""},"desc":"Remove unused variable \"extractNarrators\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'isAudimetaSource' is defined but never used.","line":917,"column":3,"messageId":"unusedVar","endLine":917,"endColumn":19,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"isAudimetaSource"},"fix":{"range":[35878,35898],"text":""},"desc":"Remove unused variable \"isAudimetaSource\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'extractSubtitle' is defined but never used.","line":918,"column":3,"messageId":"unusedVar","endLine":918,"endColumn":18,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"extractSubtitle"},"fix":{"range":[35898,35917],"text":""},"desc":"Remove unused variable \"extractSubtitle\"."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":2248,"column":79,"messageId":"unexpectedAny","endLine":2248,"endColumn":82,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[85366,85369],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[85366,85369],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":4,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\content\\CalendarView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\content\\SearchView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\content\\WantedView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\library\\AudiobookDetailView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\library\\AudiobooksView.vue","messages":[],"suppressedMessages":[{"ruleId":"@typescript-eslint/ban-ts-comment","severity":2,"message":"Use \"@ts-expect-error\" instead of \"@ts-ignore\", as \"@ts-ignore\" will do nothing if the following line is error-free.","line":1202,"column":13,"messageId":"tsIgnoreInsteadOfExpectError","endLine":1202,"endColumn":26,"suggestions":[{"messageId":"replaceTsIgnoreWithTsExpectError","fix":{"range":[44557,44570],"text":"// @ts-expect-error"},"desc":"Replace \"@ts-ignore\" with \"@ts-expect-error\"."}],"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"@typescript-eslint/ban-ts-comment","severity":2,"message":"Use \"@ts-expect-error\" instead of \"@ts-ignore\", as \"@ts-ignore\" will do nothing if the following line is error-free.","line":1205,"column":15,"messageId":"tsIgnoreInsteadOfExpectError","endLine":1205,"endColumn":28,"suggestions":[{"messageId":"replaceTsIgnoreWithTsExpectError","fix":{"range":[44730,44743],"text":"// @ts-expect-error"},"desc":"Replace \"@ts-ignore\" with \"@ts-expect-error\"."}],"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\library\\CollectionView.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'apiService' is defined but never used.","line":384,"column":10,"messageId":"unusedVar","endLine":384,"endColumn":20,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"apiService"},"fix":{"range":[12857,12901],"text":""},"desc":"Remove unused import declaration."}]}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\library\\LibraryImportView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\DiscordBotTab.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'Checkbox' is defined but never used.","line":230,"column":8,"messageId":"unusedVar","endLine":230,"endColumn":16,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"Checkbox"},"fix":{"range":[8757,8811],"text":""},"desc":"Remove unused import declaration."}]}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\DownloadClientsTab.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'FolderBrowser' is defined but never used.","line":256,"column":8,"messageId":"unusedVar","endLine":256,"endColumn":21,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"FolderBrowser"},"fix":{"range":[9893,9955],"text":""},"desc":"Remove unused import declaration."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'Modal' is defined but never used.","line":258,"column":10,"messageId":"unusedVar","endLine":258,"endColumn":15,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"Modal"},"fix":{"range":[10059,10065],"text":""},"desc":"Remove unused variable \"Modal\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'ModalHeader' is defined but never used.","line":258,"column":17,"messageId":"unusedVar","endLine":258,"endColumn":28,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"ModalHeader"},"fix":{"range":[10064,10077],"text":""},"desc":"Remove unused variable \"ModalHeader\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'ModalFooter' is defined but never used.","line":258,"column":30,"messageId":"unusedVar","endLine":258,"endColumn":41,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"ModalFooter"},"fix":{"range":[10077,10090],"text":""},"desc":"Remove unused variable \"ModalFooter\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'ModalForm' is defined but never used.","line":258,"column":43,"messageId":"unusedVar","endLine":258,"endColumn":52,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"ModalForm"},"fix":{"range":[10090,10101],"text":""},"desc":"Remove unused variable \"ModalForm\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'ModalBody' is defined but never used.","line":258,"column":54,"messageId":"unusedVar","endLine":258,"endColumn":63,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"ModalBody"},"fix":{"range":[10050,10145],"text":""},"desc":"Remove unused import declaration."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'FormSection' is defined but never used.","line":261,"column":8,"messageId":"unusedVar","endLine":261,"endColumn":19,"suggestions":[{"messageId":"removeUnusedImportDeclaration","data":{"varName":"FormSection"},"fix":{"range":[10319,10383],"text":""},"desc":"Remove unused import declaration."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhWarningCircle' is defined but never used.","line":279,"column":3,"messageId":"unusedVar","endLine":279,"endColumn":18,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhWarningCircle"},"fix":{"range":[10656,10675],"text":""},"desc":"Remove unused variable \"PhWarningCircle\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":280,"column":3,"messageId":"unusedVar","endLine":280,"endColumn":6,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[10675,10682],"text":""},"desc":"Remove unused variable \"PhX\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhCheck' is defined but never used.","line":281,"column":3,"messageId":"unusedVar","endLine":281,"endColumn":10,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhCheck"},"fix":{"range":[10682,10693],"text":""},"desc":"Remove unused variable \"PhCheck\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'getClientTypeClass' is assigned a value but never used.","line":322,"column":7,"messageId":"unusedVar","endLine":322,"endColumn":25}],"suppressedMessages":[],"errorCount":11,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\GeneralSettingsTab.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\IndexersTab.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhWarningCircle' is defined but never used.","line":245,"column":3,"messageId":"unusedVar","endLine":245,"endColumn":18,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhWarningCircle"},"fix":{"range":[9093,9112],"text":""},"desc":"Remove unused variable \"PhWarningCircle\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhPlus' is defined but never used.","line":246,"column":3,"messageId":"unusedVar","endLine":246,"endColumn":9,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhPlus"},"fix":{"range":[9112,9122],"text":""},"desc":"Remove unused variable \"PhPlus\"."}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'PhX' is defined but never used.","line":247,"column":3,"messageId":"unusedVar","endLine":247,"endColumn":6,"suggestions":[{"messageId":"removeUnusedVar","data":{"varName":"PhX"},"fix":{"range":[9122,9129],"text":""},"desc":"Remove unused variable \"PhX\"."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\NotificationsTab.vue","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":629,"column":16,"messageId":"unusedVar","endLine":629,"endColumn":17},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":642,"column":16,"messageId":"unusedVar","endLine":642,"endColumn":17},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":659,"column":18,"messageId":"unusedVar","endLine":659,"endColumn":19},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":667,"column":14,"messageId":"unusedVar","endLine":667,"endColumn":15},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":671,"column":12,"messageId":"unusedVar","endLine":671,"endColumn":13},{"ruleId":"@typescript-eslint/no-unused-vars","severity":2,"message":"'e' is defined but never used.","line":704,"column":18,"messageId":"unusedVar","endLine":704,"endColumn":19}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\n\n\n\n\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\QualityProfilesTab.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\settings\\RootFoldersTab.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\src\\views\\system\\SystemView.vue","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\tools\\generate-m3-colors.cjs","messages":[{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":2,"column":12,"messageId":"noRequireImports","endLine":2,"endColumn":25},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":3,"column":14,"messageId":"noRequireImports","endLine":3,"endColumn":29},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":4,"column":38,"messageId":"noRequireImports","endLine":4,"endColumn":83},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":5,"column":18,"messageId":"noRequireImports","endLine":5,"endColumn":63},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":6,"column":14,"messageId":"noRequireImports","endLine":6,"endColumn":33}],"suppressedMessages":[],"errorCount":5,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/* CommonJS version for node execution in this repo (generate tonal samples) */\nconst fs = require('fs')\nconst path = require('path')\nconst { argbFromHex, hexFromArgb } = require('@material/material-color-utilities').argb\nconst palettes = require('@material/material-color-utilities').palettes\nconst argv = require('minimist')(process.argv.slice(2))\n\nconst seed = (argv.seed || '#2196f3').trim()\nconst out = argv.out || 'm3-generated.css'\n\nfunction tonalPalette(hex) {\n const argb = argbFromHex(hex)\n const tonal = palettes.tonalPaletteFromArgb(argb)\n const palette = {};\n const tones = [0,10,20,30,40,50,60,70,80,90,95,99];\n tones.forEach((t) => {\n const c = palettes.tone(tonal, t)\n palette[`t${t}`] = hexFromArgb(c).toUpperCase()\n })\n return palette\n}\n\nconst palette = tonalPalette(seed)\nlet css = `/* Generated from seed ${seed} */\\n:root {\\n`;\ncss += ` /* primary tonal samples */\\n`;\ncss += ` --m3-primary-40: ${palette.t40};\\n`;\ncss += ` --m3-primary-90: ${palette.t90};\\n`;\ncss += ` --m3-primary-100: ${palette.t99};\\n`;\ncss += `}\\n`;\n\nfs.writeFileSync(path.join(process.cwd(), out), css)\nconsole.log(`Wrote ${out} (seed ${seed})`)\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\tools\\generate-m3-colors.js","messages":[{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":15,"column":12,"messageId":"noRequireImports","endLine":15,"endColumn":25},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":16,"column":14,"messageId":"noRequireImports","endLine":16,"endColumn":29},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":17,"column":38,"messageId":"noRequireImports","endLine":17,"endColumn":83},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":18,"column":18,"messageId":"noRequireImports","endLine":18,"endColumn":63},{"ruleId":"@typescript-eslint/no-require-imports","severity":2,"message":"A `require()` style import is forbidden.","line":19,"column":14,"messageId":"noRequireImports","endLine":19,"endColumn":33}],"suppressedMessages":[],"errorCount":5,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/*\n generate-m3-colors.js\n Simple script to generate M3 tonal palette CSS variables from a seed color using\n @material/material-color-utilities. Run from project root:\n\n cd fe\n npm install @material/material-color-utilities\n node tools/generate-m3-colors.js --seed #2196f3 --out m3-generated.css\n\n The script prints CSS variable definitions mapping key roles to tones. Use these\n variables to replace the approximations in `src/assets/base.css` if you want an\n accurate tonal system that follows Material 3.\n*/\n\nconst fs = require('fs')\nconst path = require('path')\nconst { argbFromHex, hexFromArgb } = require('@material/material-color-utilities').argb\nconst palettes = require('@material/material-color-utilities').palettes\nconst argv = require('minimist')(process.argv.slice(2))\n\nconst seed = (argv.seed || '#2196f3').trim()\nconst out = argv.out || 'm3-generated.css'\n\nfunction tonalPalette(hex) {\n const argb = argbFromHex(hex)\n const tonal = palettes.tonalPaletteFromArgb(argb)\n // tonal.asList(): index 0-100 corresponds to tones (not direct indexes), but utilities provide mapping\n // We pull common tones: 40=primary, 100=primaryContainer, 100? adjust as needed\n const palette = {};\n const tones = [0,10,20,30,40,50,60,70,80,90,95,99];\n tones.forEach((t) => {\n const c = palettes.tone(tonal, t)\n palette[`t${t}`] = hexFromArgb(c).toUpperCase()\n })\n return palette\n}\n\nconst palette = tonalPalette(seed)\nlet css = `/* Generated from seed ${seed} */\\n:root {\\n`;\ncss += ` /* primary tonal samples */\\n`;\ncss += ` --m3-primary-40: ${palette.t40};\\n`;\ncss += ` --m3-primary-90: ${palette.t90};\\n`;\ncss += ` --m3-primary-100: ${palette.t99};\\n`;\ncss += `}\\n`;\n\nfs.writeFileSync(path.join(process.cwd(), out), css)\nconsole.log(`Wrote ${out} (seed ${seed})`)\n","usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\vite.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\vitest.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"C:\\Users\\Robbie\\Documents\\GitHub\\Listenarr\\fe\\vitest.no-setup.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]}] diff --git a/fe/eslint.config.ts b/fe/eslint.config.ts index a88d84459..d7b32bd67 100644 --- a/fe/eslint.config.ts +++ b/fe/eslint.config.ts @@ -25,7 +25,11 @@ export default defineConfigWithVueTs( { ...pluginVitest.configs.recommended, - files: ['src/**/__tests__/*'], + files: ['src/**/test/**/*.{ts,tsx,js,jsx}'], + rules: { + ...pluginVitest.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + }, }, { diff --git a/fe/package-lock.json b/fe/package-lock.json index bdc869c8b..243a3d260 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -25,6 +25,7 @@ "@types/jsdom": "^28.0.1", "@types/node": "^25.6.0", "@vitejs/plugin-vue": "^6.0.6", + "@vitest/coverage-v8": "^4.1.5", "@vitest/eslint-plugin": "^1.6.16", "@vue/compiler-sfc": "^3.5.33", "@vue/eslint-config-prettier": "^10.2.0", @@ -146,9 +147,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -173,6 +174,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1663,6 +1674,37 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/eslint-plugin": { "version": "1.6.16", "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.16.tgz", @@ -1695,16 +1737,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1713,13 +1755,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1750,9 +1792,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -1763,13 +1805,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1777,14 +1819,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1793,9 +1835,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1803,13 +1845,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2365,6 +2407,28 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/ast-walker-scope": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", @@ -4459,6 +4523,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", @@ -4765,6 +4836,58 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -4842,6 +4965,13 @@ "node": ">=14" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", @@ -5563,6 +5693,34 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7983,19 +8141,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8023,12 +8181,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/fe/package.json b/fe/package.json index ea11be8ae..49f35296a 100644 --- a/fe/package.json +++ b/fe/package.json @@ -11,6 +11,7 @@ "predev": "npm run version:sync", "prebuild": "npm run version:sync", "pretest:unit": "npm run version:sync", + "pretest:coverage": "npm run version:sync", "prelint": "npm run version:sync", "prelint:check": "npm run version:sync", "prelint:fix": "npm run version:sync", @@ -19,17 +20,25 @@ "dev:api": "cd ../listenarr.api && dotnet run", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", - "test": "vitest", - "test:unit": "vitest run --reporter=dot --silent", + "verify": "npm run lint:check && npm run type-check && npm run test:coverage", + "test": "node --no-warnings ./node_modules/vitest/vitest.mjs", + "test:unit": "node --no-warnings ./node_modules/vitest/vitest.mjs run --reporter=dot --silent", + "test:unit:node": "node --no-warnings ./node_modules/vitest/vitest.mjs run --project unit-node --reporter=dot --silent", + "test:unit:jsdom": "node --no-warnings ./node_modules/vitest/vitest.mjs run --project unit-jsdom --reporter=dot --silent", + "test:smoke": "node --no-warnings ./node_modules/vitest/vitest.mjs run --project smoke --reporter=dot --silent", + "test:coverage": "node --no-warnings ./node_modules/vitest/vitest.mjs run --coverage --reporter=dot --silent", "prepare": "cypress install", "postinstall": "patch-package && npm run version:sync", "test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'", "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'", "build-only": "vite build", - "type-check": "vue-tsc --build tsconfig.app.json", - "lint": "npm run lint:eslint && npm run lint:vue-handlers", - "lint:check": "npm run lint:eslint && npm run lint:vue-handlers", + "type-check": "run-p type-check:app type-check:test", + "type-check:app": "vue-tsc --build tsconfig.app.json", + "type-check:test": "vue-tsc --build tsconfig.vitest.json", + "lint": "npm run lint:test-structure && npm run lint:eslint && npm run lint:vue-handlers", + "lint:check": "npm run lint:test-structure && npm run lint:eslint && npm run lint:vue-handlers", "lint:eslint": "eslint .", + "lint:test-structure": "node scripts/check-test-structure.mjs", "lint:vue-handlers": "node scripts/check-vue-template-handlers.mjs src", "lint:fix": "eslint . --fix", "format:check": "prettier --check src/", @@ -52,6 +61,7 @@ "@types/jsdom": "^28.0.1", "@types/node": "^25.6.0", "@vitejs/plugin-vue": "^6.0.6", + "@vitest/coverage-v8": "^4.1.5", "@vitest/eslint-plugin": "^1.6.16", "@vue/compiler-sfc": "^3.5.33", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/fe/scripts/check-test-structure.mjs b/fe/scripts/check-test-structure.mjs new file mode 100644 index 000000000..133a352d5 --- /dev/null +++ b/fe/scripts/check-test-structure.mjs @@ -0,0 +1,97 @@ +import fs from 'node:fs' +import path from 'node:path' + +const root = process.cwd() +const srcRoot = path.join(root, 'src') +const failures = [] + +function walk(dir) { + if (!fs.existsSync(dir)) return [] + + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries.flatMap((entry) => { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) return walk(fullPath) + return fullPath + }) +} + +function rel(file) { + return path.relative(root, file).replaceAll(path.sep, '/') +} + +const files = walk(srcRoot) +const testFilePattern = /\.(spec|test)\.(ts|tsx|js|jsx)$/ +const sharedTestBuckets = new Set(['app', 'framework', 'smoke']) +const vitestConfigPattern = /^vitest(?:\..+)?\.config\.ts$/ +const allowedSetupFile = 'src/test/setup/signalr.ts' +const allowedSetupFiles = new Set([allowedSetupFile]) + +if (fs.existsSync(path.join(srcRoot, '__tests__'))) { + failures.push('src/__tests__ must not exist; colocate tests in src/**/test/.') +} + +if (fs.existsSync(path.join(srcRoot, 'test', 'setup.ts'))) { + failures.push('src/test/setup.ts must not exist; use explicit setup files under src/test/setup/.') +} + +const setupRoot = path.join(srcRoot, 'test', 'setup') +for (const setupFile of walk(setupRoot)) { + const relative = rel(setupFile) + if (!allowedSetupFiles.has(relative)) { + failures.push(`${relative} is not an allowed Vitest setup file.`) + } +} + +for (const file of files) { + const relative = rel(file) + + if (testFilePattern.test(file)) { + if (!relative.includes('/test/')) { + failures.push(`${relative} is outside a colocated test/ folder.`) + } + + if (relative.startsWith('src/test/')) { + const bucket = relative.split('/')[2] + if (!sharedTestBuckets.has(bucket)) { + failures.push( + `${relative} is under src/test; only app, framework, and smoke specs may live there.`, + ) + } + } + } + + if (/\.test\.(ts|tsx|js|jsx)$/.test(file)) { + failures.push(`${relative} uses .test.*; use .spec.ts for frontend tests.`) + } + + if (/\.(spec|test)\.(tsx|js|jsx)$/.test(file)) { + failures.push(`${relative} is not a .spec.ts test file.`) + } +} + +const vitestConfigFiles = fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && vitestConfigPattern.test(entry.name)) + .map((entry) => entry.name) + +for (const configName of vitestConfigFiles) { + const configPath = path.join(root, configName) + const content = fs.readFileSync(configPath, 'utf8') + if ( + content.includes('setupFiles') && + !/setupFiles:\s*\[\s*['"]src\/test\/setup\/signalr\.ts['"]\s*\]/.test(content) + ) { + failures.push(`${configName} may only configure setupFiles for ${allowedSetupFile}.`) + } +} + +if (failures.length > 0) { + console.error('Frontend test structure check failed:') + for (const failure of failures) { + console.error(`- ${failure}`) + } + process.exit(1) +} + +console.log('Frontend test structure check passed.') diff --git a/fe/src/__tests__/debug_AddNew.spec.ts b/fe/src/__tests__/debug_AddNew.spec.ts deleted file mode 100644 index 932f46770..000000000 --- a/fe/src/__tests__/debug_AddNew.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -import { describe } from 'vitest' - -describe.todo('debug AddNew placeholder') diff --git a/fe/src/__tests__/sanity.js b/fe/src/__tests__/sanity.js deleted file mode 100644 index 28c6207d8..000000000 --- a/fe/src/__tests__/sanity.js +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest' - -describe('sanity js', () => { - it('runs a basic test', () => { - expect(1 + 1).toBe(2) - }) -}) diff --git a/fe/src/__tests__/sanity.spec.js b/fe/src/__tests__/sanity.spec.js deleted file mode 100644 index 28c6207d8..000000000 --- a/fe/src/__tests__/sanity.spec.js +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest' - -describe('sanity js', () => { - it('runs a basic test', () => { - expect(1 + 1).toBe(2) - }) -}) diff --git a/fe/src/__tests__/test-setup.ts b/fe/src/__tests__/test-setup.ts deleted file mode 100644 index 45f6acd29..000000000 --- a/fe/src/__tests__/test-setup.ts +++ /dev/null @@ -1,458 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -// Test setup: Polyfill / mock environment pieces that tests expect -// - Provide a Mock WebSocket implementation so SignalR code can run in jsdom - -class MockWebSocket { - static OPEN = 1 - public readyState = MockWebSocket.OPEN - public onopen: (() => void) | null = null - public onmessage: ((ev: { data: string }) => void) | null = null - public onerror: ((err: Error) => void) | null = null - public onclose: (() => void) | null = null - private url: string - constructor(url: string) { - this.url = url - // simulate async open - setTimeout(() => { - if (this.onopen) this.onopen() - }, 0) - } - send(_data: string) { - // Reference the arg so linters don't complain about unused params in tests - void _data - /* no-op in tests */ - } - close() { - if (this.onclose) this.onclose() - } -} - -// Centralized apiService and signalR mocks used by unit tests. -import { vi } from 'vitest' -import fs from 'fs' - -// Diagnostic: help locate failures during test setup in CI/local runs -try { - console.log('[test-setup] initializing test setup') -} catch {} - -// Provide default component stubs for Modal teleporting components so unit tests -// render modal content inline instead of using real teleport behavior. -import { config as vtConfig } from '@vue/test-utils' -const globalConfig = (vtConfig.global ??= {} as unknown) as unknown -globalConfig.components = { - ...(globalConfig.components || {}), - // Render modal content inline with accessible dialog attributes so tests - // can query for role="dialog" and aria-* attributes reliably. - Modal: { - template: - '
', - }, - ModalHeader: { template: '' }, - ModalBody: { template: '' }, - - // Provide lightweight test stubs for commonly used components so unit tests - // don't fail on missing component resolution for icon or small base pieces. - LoadingState: { - props: ['message', 'size'], - template: - '

{{ message }}

', - }, - PhSpinner: { - props: ['size'], - template: '', - }, - // Stub the BrandLogo component so tests don't trigger static-asset resolution - BrandLogo: { - template: '
', - }, -} - -// Also mock the BrandLogo module at import-time so Vite doesn't compile the real -// SFC (which would try to resolve `/logo.svg` at build/transform time and can -// cause file:/// URL issues in the test runner). -vi.mock('@/components/base/BrandLogo.vue', () => ({ - default: { - template: '
', - }, -})) - -// Some components import the modal pieces locally (via named imports). To ensure -// tests always render the simplified accessible modal markup (and avoid teleport -// behavior), partially mock the feedback module so SFC-local imports receive the -// inline stubs while preserving other named exports from the real module. -vi.mock('@/components/feedback', async (importOriginal) => { - const actual = (await importOriginal()) as Record - const modalStub: unknown = { - emits: ['close'], - props: ['visible', 'title', 'showClose', 'size'], - template: - '
', - mounted() { - this._onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') this.$emit?.('close') - } - document.addEventListener('keydown', this._onKey) - }, - unmounted() { - if (this._onKey) document.removeEventListener('keydown', this._onKey) - }, - } - return { - ...actual, - Modal: modalStub, - ModalHeader: { - props: ['title', 'icon', 'iconLabel'], - emits: ['close'], - template: - '', - }, - ModalBody: { template: '' }, - ModalFooter: { template: '' }, - } -}) - -// Provide both the `apiService` object and common named exports that components -// import directly (e.g. `getRemotePathMappings`, `ensureImageCached`). Tests -// expect these named exports to exist on the mocked module. -vi.mock('@/services/api', () => { - const apiService = { - searchAudibleByTitleAndAuthor: vi.fn(async () => ({ totalResults: 0, results: [] })), - advancedSearch: async (params: unknown) => { - const p = params as { title?: string; author?: string } | undefined - if (p?.title) { - const mod = await import('@/services/api') - const svc = mod.apiService as unknown as { - searchAudibleByTitleAndAuthor?: ( - title: string, - author?: string, - ) => Promise<{ totalResults?: number; results?: unknown[] } | unknown> - } - if (svc.searchAudibleByTitleAndAuthor) { - const resp = (await svc.searchAudibleByTitleAndAuthor(p.title, p.author)) as unknown - const r = resp as unknown - return r?.results || r || [] - } - return [] - } - return { totalResults: 0, results: [] } - }, - getImageUrl: vi.fn((url: string) => url || ''), - getStartupConfig: vi.fn(async () => ({})), - getApplicationSettings: vi.fn(async () => ({})), - getLibrary: vi.fn(async () => []), - previewLibraryPath: vi.fn(async () => ({ path: '' })), - previewRename: vi.fn(async () => []), - executeRename: vi.fn(async () => []), - getQualityProfiles: vi.fn(async () => []), - getApiConfigurations: vi.fn(async () => []), - // add getRootFolders to apiService so tests that spy on apiService.getRootFolders work - getRootFolders: vi.fn(async () => []), - - // add checkVolume to apiService so components that call `apiService.checkVolume` in - // unit tests have a sensible default value that matches the real API signature. - // Default behaviour: treat paths on the same root as sameVolume and do not break - // hardlinks. - checkVolume: vi.fn(async (sourcePath: string, destPath: string) => { - const same = - typeof sourcePath === 'string' && - typeof destPath === 'string' && - // simple heuristic: same leading path segment or same drive letter on Windows - (sourcePath.split(/[\\/]/)[1] === destPath.split(/[\\/]/)[1] || - (/^[A-Za-z]:/.test(sourcePath) && - sourcePath[0].toLowerCase() === destPath[0]?.toLowerCase())) - return { - sameVolume: Boolean(same), - willBreakHardlinks: !same, - sourceVolume: - typeof sourcePath === 'string' ? sourcePath.split(/[\\/]/)[1] || '' : undefined, - destVolume: typeof destPath === 'string' ? destPath.split(/[\\/]/)[1] || '' : undefined, - message: same ? 'Same volume' : 'Different volumes', - } - }), - } - - // Named exports commonly imported by components/tests - return { - apiService, - // Path/remote helpers - getRemotePathMappings: vi.fn(async () => []), - testDownloadClient: vi.fn(async () => ({ success: true })), - - // Image helpers - ensureImageCached: vi.fn(async (url: string) => url || ''), - - // Logs / files - getLogs: vi.fn(async () => []), - downloadLogs: vi.fn(async () => null), - - // Root folders / profiles - getRootFolders: vi.fn(async () => []), - getQualityProfiles: vi.fn(async () => []), - - // Keep the startup / app settings helpers available as named exports too - getStartupConfig: vi.fn(async () => ({})), - getApplicationSettings: vi.fn(async () => ({})), - - // Expose checkVolume as a named export as well (delegates to the apiService - // mock above) so tests that import the function directly behave the same. - checkVolume: vi.fn(async (sourcePath: string, destPath: string) => - (apiService as unknown).checkVolume(sourcePath, destPath), - ), - } -}) - -vi.mock('@/services/signalr', () => ({ - signalRService: { - connect: () => {}, - onDownloadsList: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onSearchProgress: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onQueueUpdate: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onDownloadUpdate: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onFilesRemoved: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onAudiobookUpdate: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onNotification: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - onToast: (cb?: (...args: unknown[]) => void) => { - void cb - return () => {} - }, - }, -})) - -// Ensure global WebSocket exists for code that references it -if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as { WebSocket?: unknown }).WebSocket = MockWebSocket -} - -// Also provide a minimal window.WebSocket for code referencing window -if (typeof (window as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(window as unknown as { WebSocket?: unknown }).WebSocket = MockWebSocket -} - -// Provide a noop for console.debug in tests where code wraps in try/catch -if (typeof console.debug !== 'function') console.debug = console.log.bind(console) - -// Ensure JSDOM's base URL is HTTP (not file://) so absolute static asset paths -// (e.g. `/logo.svg`) resolve to `http://localhost/...` instead of `file:///...`. -// On Windows the `file:///` form can surface in source-maps and cause Node APIs -// to reject the path; setting the location prevents those file URLs from -// appearing during transforms and stacktrace processing. -try { - if ( - typeof window !== 'undefined' && - window.location && - window.location.href.startsWith('file:') - ) { - // Replace file://... base with http://localhost/ - window.history.replaceState({}, '', 'http://localhost/') - } - // Also ensure document.baseURI is an HTTP URL (some code consults baseURI directly) - if (typeof document !== 'undefined' && document.baseURI && document.baseURI.startsWith('file:')) { - try { - Object.defineProperty(document, 'baseURI', { value: 'http://localhost/', configurable: true }) - } catch {} - } -} catch {} - -// Provide a simple localStorage polyfill for tests that rely on it -// Ensure a working localStorage implementation exists for tests. Some test -// runners may set a placeholder object; normalize it so .setItem/.getItem exist. -if ( - typeof (globalThis as unknown as { localStorage?: { setItem?: unknown } }).localStorage === - 'undefined' || - typeof (globalThis as unknown as { localStorage?: { setItem?: unknown } }).localStorage - ?.setItem !== 'function' -) { - ;( - globalThis as unknown as { - localStorage?: { - _store?: Record - getItem?: (k: string) => string | null - setItem?: (k: string, v: string) => void - removeItem?: (k: string) => void - clear?: () => void - } - } - ).localStorage = { - _store: {} as Record, - getItem(key: string) { - return this._store[key] ?? null - }, - setItem(key: string, value: string) { - this._store[key] = value + '' - }, - removeItem(key: string) { - delete this._store[key] - }, - clear() { - this._store = {} - }, - } -} - -// Defensive: JSDOM / Vitest may encounter `file://` asset URLs (e.g. transformed -// static asset paths like `file:///logo.svg`). Some environments propagate -// those to HTMLImageElement.src setters which can trigger Node internal URL/path -// handling and cause tests to crash. Normalize `file://` image URLs to plain -// absolute paths to avoid runtime errors during tests. -try { - const imgProto = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src') - Object.defineProperty(HTMLImageElement.prototype, 'src', { - set(this: HTMLImageElement, value: string) { - try { - if (typeof value === 'string' && value.startsWith('file:///')) { - // Convert file URL (file:///logo.svg) to a usable pathname (/logo.svg) - const u = new URL(value) - return imgProto?.set?.call(this, u.pathname) - } - } catch { - // fall through to default setter - } - return imgProto?.set?.call(this, value) - }, - get(this: HTMLImageElement) { - return imgProto?.get?.call(this) - }, - configurable: true, - }) - - // Some code sets src via setAttribute('src', ...) which bypasses the property - // setter. Intercept attribute assignments and normalize file:// URLs for src. - const origSetAttr = Element.prototype.setAttribute - Element.prototype.setAttribute = function (name: string, value: string) { - try { - if ( - typeof name === 'string' && - name.toLowerCase() === 'src' && - typeof value === 'string' && - value.startsWith('file:///') - ) { - const u = new URL(value) - return origSetAttr.call(this, name, u.pathname) - } - } catch {} - return origSetAttr.call(this, name, value) - } -} catch {} - -// Defensive: some test runners / source-map consumers may attempt to read 'file:///logo.svg' -// which can throw on Windows / Node file APIs. Normalize any `file:///...` paths to an -// absolute pathname or return an empty string so tests don't crash during stacktrace -// or source-map processing. -try { - const _fs = fs - const _origRead = _fs.readFile.bind(_fs) - const _origReadSync = _fs.readFileSync.bind(_fs) - const _origExistsSync = _fs.existsSync.bind(_fs) - const _origStatSync = _fs.statSync.bind(_fs) - const _origRealpathSync = _fs.realpathSync.bind(_fs) - const _origCreateReadStream = _fs.createReadStream.bind(_fs) - const _origOpenSync = _fs.openSync ? _fs.openSync.bind(_fs) : undefined - const _origPromisesRead = - _fs.promises && _fs.promises.readFile ? _fs.promises.readFile.bind(_fs.promises) : undefined - - function normalizePathArg(p: unknown) { - try { - if (typeof p === 'string' && p.startsWith('file:///')) { - // Convert 'file:///logo.svg' -> '/logo.svg' (safe for test runtime) - return new URL(p).pathname - } - } catch {} - return p - } - - function isProblematicLogoPath(p: unknown) { - const np = typeof p === 'string' ? p : '' - return np === 'file:///logo.svg' || np.endsWith('/logo.svg') - } - - ;(_fs as unknown).readFile = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) { - const cb = args[args.length - 1] - if (typeof cb === 'function') return cb(null, '') - return Promise.resolve('') - } - return _origRead(path, ...args) - } - ;(_fs as unknown).readFileSync = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return '' - return _origReadSync(path, ...args) - } - - if (_origPromisesRead) { - ;(_fs as unknown).promises.readFile = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return Promise.resolve('') - return _origPromisesRead(path, ...args) - } - } - - ;(_fs as unknown).existsSync = function (p: unknown) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return true - return _origExistsSync(path) - } - ;(_fs as unknown).statSync = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return { isFile: () => true, isDirectory: () => false } as unknown - return _origStatSync(path, ...args) - } - ;(_fs as unknown).realpathSync = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return path - return _origRealpathSync(path, ...args) - } - ;(_fs as unknown).createReadStream = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return _origCreateReadStream('/dev/null') - return _origCreateReadStream(path, ...args) - } - - if (_origOpenSync) { - ;(_fs as unknown).openSync = function (p: unknown, ...args: unknown[]) { - const path = normalizePathArg(p) - if (isProblematicLogoPath(p)) return _origOpenSync(path) - return _origOpenSync(path, ...args) - } - } -} catch {} diff --git a/fe/src/components/base/BrandLogo.vue b/fe/src/components/base/BrandLogo.vue index 18b20eaf4..2eef4a72b 100644 --- a/fe/src/components/base/BrandLogo.vue +++ b/fe/src/components/base/BrandLogo.vue @@ -16,10 +16,11 @@ along with this program. If not, see . --> diff --git a/fe/src/__tests__/AddLibraryModal.accessibility.spec.ts b/fe/src/components/domain/audiobook/test/AddLibraryModal.accessibility.spec.ts similarity index 93% rename from fe/src/__tests__/AddLibraryModal.accessibility.spec.ts rename to fe/src/components/domain/audiobook/test/AddLibraryModal.accessibility.spec.ts index 0a1448617..7e8d5d15e 100644 --- a/fe/src/__tests__/AddLibraryModal.accessibility.spec.ts +++ b/fe/src/components/domain/audiobook/test/AddLibraryModal.accessibility.spec.ts @@ -17,6 +17,7 @@ */ import { mount } from '@vue/test-utils' import { vi, describe, it, expect } from 'vitest' +import { modalStubs } from '@/test/stubs' // Mock apiService methods used during mount/seedPreview to avoid network calls vi.mock('@/services/api', () => ({ @@ -30,6 +31,7 @@ vi.mock('@/services/api', () => ({ })) import AddLibraryModal from '@/components/domain/audiobook/AddLibraryModal.vue' +import { flushAsync } from '@/test/utils/wait' const fakeBook = { title: 'Test Title', @@ -48,12 +50,13 @@ describe('AddLibraryModal accessibility', () => { attachTo: document.body, global: { plugins: [(await import('pinia')).createPinia()], + stubs: modalStubs, }, }) await wrapper.setProps({ visible: true }) // allow watchers to run - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // dialog exists const dialog = wrapper.find('[role="dialog"]') @@ -66,7 +69,7 @@ describe('AddLibraryModal accessibility', () => { document.dispatchEvent(escEvent) // allow event loop - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() const emitted = wrapper.emitted('close') expect(emitted).toBeTruthy() diff --git a/fe/src/__tests__/AddLibraryModal.editableMetadata.spec.ts b/fe/src/components/domain/audiobook/test/AddLibraryModal.editableMetadata.spec.ts similarity index 98% rename from fe/src/__tests__/AddLibraryModal.editableMetadata.spec.ts rename to fe/src/components/domain/audiobook/test/AddLibraryModal.editableMetadata.spec.ts index 8015d2529..3bd8f03cc 100644 --- a/fe/src/__tests__/AddLibraryModal.editableMetadata.spec.ts +++ b/fe/src/components/domain/audiobook/test/AddLibraryModal.editableMetadata.spec.ts @@ -18,6 +18,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia } from 'pinia' import { vi, describe, it, expect, beforeEach } from 'vitest' +import { modalStubs } from '@/test/stubs' const apiMocks = vi.hoisted(() => ({ getAudibleMetadata: vi.fn(), @@ -90,6 +91,7 @@ describe('AddLibraryModal editable metadata', () => { attachTo: document.body, global: { plugins: [createPinia()], + stubs: modalStubs, }, }) diff --git a/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts b/fe/src/components/domain/audiobook/test/AddLibraryModal.relativePath.spec.ts similarity index 94% rename from fe/src/__tests__/AddLibraryModal.relativePath.spec.ts rename to fe/src/components/domain/audiobook/test/AddLibraryModal.relativePath.spec.ts index be2854918..4eace4a2e 100644 --- a/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts +++ b/fe/src/components/domain/audiobook/test/AddLibraryModal.relativePath.spec.ts @@ -17,6 +17,7 @@ */ import { mount } from '@vue/test-utils' import { vi, describe, it, expect } from 'vitest' +import { modalStubs } from '@/test/stubs' // Mock apiService methods used during mount/seedPreview to avoid network calls vi.mock('@/services/api', () => ({ @@ -32,6 +33,7 @@ vi.mock('@/services/api', () => ({ })) import AddLibraryModal from '@/components/domain/audiobook/AddLibraryModal.vue' +import { delay } from '@/test/utils/wait' const fakeBook = { title: 'Test Title', @@ -50,12 +52,13 @@ describe('AddLibraryModal relative path derivation', () => { attachTo: document.body, global: { plugins: [(await import('pinia')).createPinia()], + stubs: modalStubs, }, }) await wrapper.setProps({ visible: true }) // allow watchers / async ops - await new Promise((r) => setTimeout(r, 10)) + await delay(10) const input = wrapper.find('input.relative-input') expect(input.exists()).toBe(true) diff --git a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts b/fe/src/components/domain/audiobook/test/EditAudiobookModal.moveOptions.spec.ts similarity index 88% rename from fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts rename to fe/src/components/domain/audiobook/test/EditAudiobookModal.moveOptions.spec.ts index 17590007a..8ab19d1c3 100644 --- a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts +++ b/fe/src/components/domain/audiobook/test/EditAudiobookModal.moveOptions.spec.ts @@ -17,6 +17,7 @@ */ import { mount } from '@vue/test-utils' import { vi, describe, it, expect, beforeEach } from 'vitest' +import { modalStubs } from '@/test/stubs' vi.mock('@/services/api', () => ({ apiService: { @@ -42,6 +43,7 @@ vi.mock('@/services/signalr', () => ({ })) import EditAudiobookModal from '@/components/domain/audiobook/EditAudiobookModal.vue' +import { delay } from '@/test/utils/wait' const audiobook = { id: 1, @@ -61,28 +63,28 @@ describe('EditAudiobookModal move options', () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, attachTo: document.body, - global: { plugins: [(await import('pinia')).createPinia()] }, + global: { plugins: [(await import('pinia')).createPinia()], stubs: modalStubs }, }) // let init settle - await new Promise((r) => setTimeout(r, 200)) + await delay(200) // Ensure there is a detectable change: set an explicit custom root and flip monitored - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' - ;(wrapper.vm as unknown).formData.monitored = false + ;(wrapper.vm as any).selectedRootId = 0 + ;(wrapper.vm as any).customRootPath = 'C:\\root\\New Author\\New Book' + ;(wrapper.vm as any).formData.monitored = false await wrapper.vm.$nextTick() // Start save flow and resolve the in-component confirmation promise by // calling the module-scoped resolver if it was created. This avoids // relying on modal rendering in jsdom. - const savePromise = (wrapper.vm as unknown).handleSave() - await new Promise((r) => setTimeout(r, 10)) - const resolver = (wrapper.vm as unknown).moveConfirmResolver + const savePromise = (wrapper.vm as any).handleSave() + await delay(10) + const resolver = (wrapper.vm as any).moveConfirmResolver if (resolver) resolver({ proceed: true, moveFiles: false, deleteEmptySource: false }) await savePromise // Allow async work to settle - await new Promise((r) => setTimeout(r, 50)) + await delay(50) const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) @@ -93,27 +95,27 @@ describe('EditAudiobookModal move options', () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, attachTo: document.body, - global: { plugins: [(await import('pinia')).createPinia()] }, + global: { plugins: [(await import('pinia')).createPinia()], stubs: modalStubs }, }) - await new Promise((r) => setTimeout(r, 200)) + await delay(200) // Ensure there is a detectable change: set an explicit custom root and flip monitored - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' - ;(wrapper.vm as unknown).formData.monitored = false + ;(wrapper.vm as any).selectedRootId = 0 + ;(wrapper.vm as any).customRootPath = 'C:\\root\\New Author\\New Book' + ;(wrapper.vm as any).formData.monitored = false await wrapper.vm.$nextTick() // Start save flow and resolve the in-component confirmation promise to // simulate the user choosing to move files now. - const savePromise2 = (wrapper.vm as unknown).handleSave() - await new Promise((r) => setTimeout(r, 10)) - const resolver2 = (wrapper.vm as unknown).moveConfirmResolver + const savePromise2 = (wrapper.vm as any).handleSave() + await delay(10) + const resolver2 = (wrapper.vm as any).moveConfirmResolver if (resolver2) resolver2({ proceed: true, moveFiles: true, deleteEmptySource: true }) await savePromise2 // Wait for async update + move to settle - await new Promise((r) => setTimeout(r, 50)) + await delay(50) const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) @@ -129,16 +131,15 @@ describe('EditAudiobookModal move options', () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, attachTo: document.body, - global: { plugins: [(await import('pinia')).createPinia()] }, + global: { plugins: [(await import('pinia')).createPinia()], stubs: modalStubs }, }) - await new Promise((r) => setTimeout(r, 200)) - ;(wrapper.vm as unknown as { formData: { edition: string } }).formData.edition = - 'Revised Edition' + await delay(200) + ;(wrapper.vm as any as { formData: { edition: string } }).formData.edition = 'Revised Edition' await wrapper.vm.$nextTick() - await (wrapper.vm as unknown as { handleSave: () => Promise }).handleSave() - await new Promise((r) => setTimeout(r, 50)) + await (wrapper.vm as any as { handleSave: () => Promise }).handleSave() + await delay(50) const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledWith( @@ -168,12 +169,12 @@ describe('EditAudiobookModal move options', () => { }, }, attachTo: document.body, - global: { plugins: [(await import('pinia')).createPinia()] }, + global: { plugins: [(await import('pinia')).createPinia()], stubs: modalStubs }, }) - await new Promise((r) => setTimeout(r, 200)) + await delay(200) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { formData: { title: string subtitle: string @@ -217,7 +218,7 @@ describe('EditAudiobookModal move options', () => { await wrapper.vm.$nextTick() await vm.handleSave() - await new Promise((r) => setTimeout(r, 50)) + await delay(50) const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledWith( @@ -287,10 +288,10 @@ describe('EditAudiobookModal move options', () => { audiobook, }, attachTo: document.body, - global: { plugins: [(await import('pinia')).createPinia()] }, + global: { plugins: [(await import('pinia')).createPinia()], stubs: modalStubs }, }) - await new Promise((r) => setTimeout(r, 50)) + await delay(50) expect(wrapper.text()).toContain('Edit Audiobook: Sample') expect((wrapper.get('#metadata-title').element as HTMLInputElement).value).toBe('Sample') @@ -340,10 +341,10 @@ describe('EditAudiobookModal move options', () => { audiobook, }, attachTo: document.body, - global: { plugins: [(await import('pinia')).createPinia()] }, + global: { plugins: [(await import('pinia')).createPinia()], stubs: modalStubs }, }) - await new Promise((r) => setTimeout(r, 50)) + await delay(50) await wrapper.setProps({ audiobook: { @@ -353,7 +354,7 @@ describe('EditAudiobookModal move options', () => { narrators: ['Narrator One'], }, }) - await new Promise((r) => setTimeout(r, 50)) + await delay(50) expect((wrapper.get('#metadata-description').element as HTMLTextAreaElement).value).toBe( 'Loaded from refreshed detail payload', diff --git a/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts b/fe/src/components/domain/audiobook/test/EditAudiobookModal.relativePath.spec.ts similarity index 77% rename from fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts rename to fe/src/components/domain/audiobook/test/EditAudiobookModal.relativePath.spec.ts index aa36c94e2..25892cff1 100644 --- a/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts +++ b/fe/src/components/domain/audiobook/test/EditAudiobookModal.relativePath.spec.ts @@ -32,6 +32,7 @@ vi.mock('@/services/api', () => ({ })) import EditAudiobookModal from '@/components/domain/audiobook/EditAudiobookModal.vue' +import { delay } from '@/test/utils/wait' const audiobook = { id: 1, @@ -56,10 +57,10 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Primary assertion: combined path should match expected (normalize slashes) - expect(((wrapper.vm as unknown).combinedBasePath() || '').replace(/\\/g, '/')).toBe( + expect(((wrapper.vm as any).combinedBasePath() || '').replace(/\\/g, '/')).toBe( 'C:/root/Some Author/Some Title', ) @@ -86,10 +87,10 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Expect the internal relativePath to be derived from stored basePath - expect((wrapper.vm as unknown).formData.relativePath).toBe('Some Author\\Some Title') + expect((wrapper.vm as any).formData.relativePath).toBe('Some Author\\Some Title') }) it('treats an exact root-folder basePath as that configured root instead of custom path', async () => { @@ -107,11 +108,11 @@ describe('EditAudiobookModal relative path calculation', () => { }, }) - await new Promise((r) => setTimeout(r, 10)) + await delay(10) - expect((wrapper.vm as unknown).selectedRootId).toBe(1) - expect((wrapper.vm as unknown).customRootPath).toBeUndefined() - expect((wrapper.vm as unknown).formData.relativePath).toBe('') + expect((wrapper.vm as any).selectedRootId).toBe(1) + expect((wrapper.vm as any).customRootPath).toBeUndefined() + expect((wrapper.vm as any).formData.relativePath).toBe('') }) it('normalizes absolute path to relative when Done is clicked', async () => { @@ -127,14 +128,14 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Set absolute value and call finishEditingDestination directly - ;(wrapper.vm as unknown).formData.relativePath = 'C:\\root\\New Author\\New Title' - await (wrapper.vm as unknown).finishEditingDestination() + ;(wrapper.vm as any).formData.relativePath = 'C:\\root\\New Author\\New Title' + await (wrapper.vm as any).finishEditingDestination() // After normalization the internal relativePath should be the short relative - expect((wrapper.vm as unknown).formData.relativePath).toBe('New Author\\New Title') + expect((wrapper.vm as any).formData.relativePath).toBe('New Author\\New Title') }) it('preserves a user-typed relative path after Done and reopen', async () => { @@ -150,14 +151,14 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Type a relative path and call Done directly - ;(wrapper.vm as unknown).formData.relativePath = 'My Author\\My Title' - await (wrapper.vm as unknown).finishEditingDestination() + ;(wrapper.vm as any).formData.relativePath = 'My Author\\My Title' + await (wrapper.vm as any).finishEditingDestination() // The internal relativePath should remain what the user typed - expect((wrapper.vm as unknown).formData.relativePath).toBe('My Author\\My Title') + expect((wrapper.vm as any).formData.relativePath).toBe('My Author\\My Title') }) it('prefills absolute path when switching to Custom path', async () => { @@ -173,14 +174,14 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Simulate switching to Custom path by setting selectedRootId - ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as any).selectedRootId = 0 await nextTick() // customRootPath should be prefilled to the full base path (normalize slashes) - expect(((wrapper.vm as unknown).customRootPath || '').replace(/\\/g, '/')).toBe( + expect(((wrapper.vm as any).customRootPath || '').replace(/\\/g, '/')).toBe( 'C:/root/Some Author/Some Title', ) }) @@ -198,16 +199,16 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Simulate selecting Custom path directly - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = (wrapper.vm as unknown).combinedBasePath() + ;(wrapper.vm as any).selectedRootId = 0 + ;(wrapper.vm as any).customRootPath = (wrapper.vm as any).combinedBasePath() await nextTick() // combinedBasePath should equal the custom path exactly (no duplication) - const cb = (wrapper.vm as unknown).combinedBasePath() - const cr = (wrapper.vm as unknown).customRootPath + const cb = (wrapper.vm as any).combinedBasePath() + const cr = (wrapper.vm as any).customRootPath expect((cb || '').replace(/\\/g, '/')).toBe((cr || '').replace(/\\/g, '/')) }) @@ -224,15 +225,15 @@ describe('EditAudiobookModal relative path calculation', () => { }) // allow async init - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Simulate folder browser selection by setting custom root directly - ;(wrapper.vm as unknown).selectedRootId = 0 - ;(wrapper.vm as unknown).customRootPath = 'C:\\temp\\Isaac Asimov\\Foundation' + ;(wrapper.vm as any).selectedRootId = 0 + ;(wrapper.vm as any).customRootPath = 'C:\\temp\\Isaac Asimov\\Foundation' await nextTick() // combinedBasePath should equal the selected custom root exactly - const cb = (wrapper.vm as unknown).combinedBasePath() + const cb = (wrapper.vm as any).combinedBasePath() expect(cb.replace(/\\/g, '/')).toBe('C:/temp/Isaac Asimov/Foundation') }) }) diff --git a/fe/src/__tests__/LibraryImportFooter.spec.ts b/fe/src/components/domain/audiobook/test/LibraryImportFooter.spec.ts similarity index 91% rename from fe/src/__tests__/LibraryImportFooter.spec.ts rename to fe/src/components/domain/audiobook/test/LibraryImportFooter.spec.ts index def1ee4f3..1e62371a6 100644 --- a/fe/src/__tests__/LibraryImportFooter.spec.ts +++ b/fe/src/components/domain/audiobook/test/LibraryImportFooter.spec.ts @@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import LibraryImportFooter from '@/components/domain/audiobook/LibraryImportFooter.vue' import { useLibraryImportStore } from '@/stores/libraryImport' import type { SearchResult, RootFolder } from '@/types' +import { flushAsync } from '@/test/utils/wait' const success = vi.fn() const error = vi.fn() @@ -51,7 +52,7 @@ describe('LibraryImportFooter', () => { folderName: 'Book 1', format: 'MP3', fileCount: 1, - selectedMatch: { title: 'Book 1', authors: [] } as unknown as SearchResult, + selectedMatch: { title: 'Book 1', authors: [] } as any as SearchResult, hasSearched: true, isSearching: false, selected: true, @@ -65,7 +66,7 @@ describe('LibraryImportFooter', () => { folderName: 'Book 2', format: 'MP3', fileCount: 1, - selectedMatch: { title: 'Book 2', authors: [] } as unknown as SearchResult, + selectedMatch: { title: 'Book 2', authors: [] } as any as SearchResult, hasSearched: true, isSearching: false, selected: true, @@ -81,7 +82,7 @@ describe('LibraryImportFooter', () => { const wrapper = mount(LibraryImportFooter, { props: { - folders: [{ id: 1, path: 'D:\\library' }] as unknown as RootFolder[], + folders: [{ id: 1, path: 'D:\\library' }] as any as RootFolder[], }, global: { plugins: [pinia], @@ -96,7 +97,7 @@ describe('LibraryImportFooter', () => { expect((importButton.element as HTMLButtonElement).disabled).toBe(true) resolveImport?.({ imported: 2, errors: [] }) - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() await wrapper.vm.$nextTick() expect(success).toHaveBeenCalledWith('Import complete', '2 books imported') diff --git a/fe/src/__tests__/LibraryImportSearchModal.spec.ts b/fe/src/components/domain/audiobook/test/LibraryImportSearchModal.spec.ts similarity index 93% rename from fe/src/__tests__/LibraryImportSearchModal.spec.ts rename to fe/src/components/domain/audiobook/test/LibraryImportSearchModal.spec.ts index 7a5d7b5db..af0778648 100644 --- a/fe/src/__tests__/LibraryImportSearchModal.spec.ts +++ b/fe/src/components/domain/audiobook/test/LibraryImportSearchModal.spec.ts @@ -20,6 +20,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { apiService } from '@/services/api' import type { SearchResult } from '@/types' import LibraryImportSearchModal from '@/components/domain/audiobook/LibraryImportSearchModal.vue' +import { modalStubs } from '@/test/stubs' +import { flushAsync } from '@/test/utils/wait' const getProtectedImageSrc = vi.fn(() => 'https://example.com/protected.jpg') @@ -38,7 +40,7 @@ describe('LibraryImportSearchModal', () => { title: 'Alchemised', imageUrl: '/api/v1/images/B000APXZHK', authors: [{ name: 'SenLinYu' }], - } as unknown as SearchResult, + } as any as SearchResult, ]) }) @@ -66,9 +68,12 @@ describe('LibraryImportSearchModal', () => { selected: false, }, }, + global: { + stubs: modalStubs, + }, }) - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() await wrapper.vm.$nextTick() expect(getProtectedImageSrc).toHaveBeenCalledWith( diff --git a/fe/src/__tests__/DownloadClientFormModal.spec.ts b/fe/src/components/domain/download/test/DownloadClientFormModal.spec.ts similarity index 82% rename from fe/src/__tests__/DownloadClientFormModal.spec.ts rename to fe/src/components/domain/download/test/DownloadClientFormModal.spec.ts index 17beed61d..6d0b72711 100644 --- a/fe/src/__tests__/DownloadClientFormModal.spec.ts +++ b/fe/src/components/domain/download/test/DownloadClientFormModal.spec.ts @@ -15,16 +15,27 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import { nextTick } from 'vue' import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' import DownloadClientFormModal from '@/components/domain/download/DownloadClientFormModal.vue' +import { modalStubs } from '@/test/stubs' + +const testDownloadClientMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/services/api', () => ({ + testDownloadClient: testDownloadClientMock, +})) describe('DownloadClientFormModal', () => { + beforeEach(() => { + testDownloadClientMock.mockReset() + }) + it('renders password input for qbittorrent', async () => { const wrapper = mount(DownloadClientFormModal, { - global: { plugins: [createPinia()] }, + global: { plugins: [createPinia()], stubs: modalStubs }, props: { visible: true, editingClient: null }, }) @@ -52,7 +63,7 @@ describe('DownloadClientFormModal', () => { it('renders api key input for sabnzbd', async () => { const wrapper = mount(DownloadClientFormModal, { - global: { plugins: [createPinia()] }, + global: { plugins: [createPinia()], stubs: modalStubs }, props: { visible: true, editingClient: null }, }) @@ -78,15 +89,14 @@ describe('DownloadClientFormModal', () => { }) it('test button on modal uses current input values and includes ID for existing client fallback', async () => { - const api = await import('@/services/api') - ;(api.testDownloadClient as unknown) = vi.fn(async (config: unknown) => ({ + testDownloadClientMock.mockImplementationOnce(async (config: unknown) => ({ success: true, message: 'ok', client: config, })) const wrapper = mount(DownloadClientFormModal, { - global: { plugins: [createPinia()] }, + global: { plugins: [createPinia()], stubs: modalStubs }, props: { visible: true, editingClient: null }, }) @@ -116,23 +126,22 @@ describe('DownloadClientFormModal', () => { expect(testButton.exists()).toBe(true) await testButton.trigger('click') - expect(api.testDownloadClient as unknown).toHaveBeenCalled() - const calledWith = (api.testDownloadClient as unknown).mock.calls[0][0] + expect(testDownloadClientMock).toHaveBeenCalled() + const calledWith = testDownloadClientMock.mock.calls[0][0] expect(calledWith.host).toBe('edited.local') // Existing client id should be sent so backend can reuse saved credentials when needed. expect(calledWith.id).toBe('3') }) it('modal sends existing client ID when password is cleared so backend can pull saved password', async () => { - const api = await import('@/services/api') - ;(api.testDownloadClient as unknown) = vi.fn(async (config: unknown) => ({ + testDownloadClientMock.mockImplementationOnce(async (config: unknown) => ({ success: true, message: 'ok', client: config, })) const wrapper = mount(DownloadClientFormModal, { - global: { plugins: [createPinia()] }, + global: { plugins: [createPinia()], stubs: modalStubs }, props: { visible: true, editingClient: null }, }) @@ -159,15 +168,15 @@ describe('DownloadClientFormModal', () => { expect(passwordComponent.props('modelValue')).toBe('dbpass') // clear the password input by emitting v-model update - await (passwordComponent.vm as unknown).$emit('update:modelValue', '') + await (passwordComponent.vm as any).$emit('update:modelValue', '') await nextTick() // click Test const testButton = wrapper.find('button.btn-info') await testButton.trigger('click') - expect(api.testDownloadClient as unknown).toHaveBeenCalled() - const calledWith = (api.testDownloadClient as unknown).mock.calls[0][0] + expect(testDownloadClientMock).toHaveBeenCalled() + const calledWith = testDownloadClientMock.mock.calls[0][0] // We still send an empty password input, but include id so backend can reuse saved credentials. expect(calledWith.password).toBe('') expect(calledWith.id).toBe('4') diff --git a/fe/src/__tests__/RenamePreviewModal.spec.ts b/fe/src/components/domain/organize/test/RenamePreviewModal.spec.ts similarity index 93% rename from fe/src/__tests__/RenamePreviewModal.spec.ts rename to fe/src/components/domain/organize/test/RenamePreviewModal.spec.ts index 4a4801138..e2b6a9cb0 100644 --- a/fe/src/__tests__/RenamePreviewModal.spec.ts +++ b/fe/src/components/domain/organize/test/RenamePreviewModal.spec.ts @@ -20,6 +20,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import RenamePreviewModal from '@/components/domain/organize/RenamePreviewModal.vue' import { apiService } from '@/services/api' import type { RenamePreview } from '@/types' +import { modalStubs } from '@/test/stubs' + +vi.mock('@/services/api', () => ({ + apiService: { + previewRename: vi.fn(), + }, +})) describe('RenamePreviewModal', () => { beforeEach(() => { @@ -53,6 +60,9 @@ describe('RenamePreviewModal', () => { visible: true, audiobookIds: [7], }, + global: { + stubs: modalStubs, + }, }) await flushPromises() diff --git a/fe/src/__tests__/ManualSearchModal.spec.ts b/fe/src/components/domain/search/test/ManualSearchModal.spec.ts similarity index 88% rename from fe/src/__tests__/ManualSearchModal.spec.ts rename to fe/src/components/domain/search/test/ManualSearchModal.spec.ts index 34bb2074f..1161de49a 100644 --- a/fe/src/__tests__/ManualSearchModal.spec.ts +++ b/fe/src/components/domain/search/test/ManualSearchModal.spec.ts @@ -20,26 +20,28 @@ import { nextTick } from 'vue' import { describe, it, expect, vi, afterEach } from 'vitest' import ManualSearchModal from '@/components/domain/search/ManualSearchModal.vue' import * as apiModule from '@/services/api' +import { modalStubs } from '@/test/stubs' +import { delay } from '@/test/utils/wait' const { apiService } = apiModule -if (!(apiService as unknown as Record).getEnabledIndexers) { - ;(apiService as unknown as { getEnabledIndexers: () => Promise }).getEnabledIndexers = +if (!(apiService as any as Record).getEnabledIndexers) { + ;(apiService as any as { getEnabledIndexers: () => Promise }).getEnabledIndexers = async () => [] } -if (!(apiService as unknown as Record).searchByApi) { - ;(apiService as unknown as { searchByApi: () => Promise }).searchByApi = async () => [] +if (!(apiService as any as Record).searchByApi) { + ;(apiService as any as { searchByApi: () => Promise }).searchByApi = async () => [] } -if (!(apiService as unknown as Record).getDefaultQualityProfile) { +if (!(apiService as any as Record).getDefaultQualityProfile) { ;( - apiService as unknown as { + apiService as any as { getDefaultQualityProfile: () => Promise<{ id: number }> } ).getDefaultQualityProfile = async () => ({ id: 1 }) } -if (!(apiService as unknown as Record).scoreSearchResults) { +if (!(apiService as any as Record).scoreSearchResults) { ;( - apiService as unknown as { + apiService as any as { scoreSearchResults: () => Promise } ).scoreSearchResults = async () => [] @@ -87,14 +89,12 @@ describe('ManualSearchModal.vue', () => { PhArrowsDownUp: true, // Ensure ScorePopover renders its default slot in tests so the inner badge is present ScorePopover: { template: '
' }, - Modal: { template: '
' }, - ModalHeader: { template: '
' }, - ModalBody: { template: '
' }, + ...modalStubs, } // Helper to set `results` on the component instance in a way that works // whether the component exposes a ref (`.value`) or an unwrapped array. - const setResultsOnVm = (vm: unknown, r: unknown) => { + const setResultsOnVm = (vm: any, r: unknown) => { if (vm && vm.results && typeof vm.results === 'object' && 'value' in vm.results) { vm.results.value = r } else if (vm) { @@ -111,7 +111,7 @@ describe('ManualSearchModal.vue', () => { props: { isOpen: true, audiobook: null }, global: { stubs }, }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { results: ManualSearchResult[] qualityScores?: QualityScoresMap } @@ -133,7 +133,6 @@ describe('ManualSearchModal.vue', () => { // Debug: show rendered HTML to investigate missing anchor - console.log(wrapper.html()) const anchor = wrapper.find('a.title-text') expect(anchor.exists()).toBe(true) expect(anchor.attributes('href')).toBe('https://indexer/info/123') @@ -144,7 +143,7 @@ describe('ManualSearchModal.vue', () => { props: { isOpen: true, audiobook: null }, global: { stubs }, }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { results: ManualSearchResult[] qualityScores?: QualityScoresMap } @@ -172,7 +171,7 @@ describe('ManualSearchModal.vue', () => { props: { isOpen: true, audiobook: null }, global: { stubs }, }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { results: ManualSearchResult[] qualityScores?: QualityScoresMap } @@ -204,7 +203,7 @@ describe('ManualSearchModal.vue', () => { props: { isOpen: true, audiobook: null }, global: { stubs }, }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { results: ManualSearchResult[] qualityScores?: QualityScoresMap } @@ -232,20 +231,20 @@ describe('ManualSearchModal.vue', () => { if ( vm.qualityScores && ( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } ).value && typeof ( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } ).value!.set === 'function' ) { ;( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } @@ -256,14 +255,14 @@ describe('ManualSearchModal.vue', () => { if ( vm.qualityScores && typeof ( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } ).set === 'function' ) { ;( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } @@ -285,7 +284,7 @@ describe('ManualSearchModal.vue', () => { props: { isOpen: true, audiobook: null }, global: { stubs }, }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { results: ManualSearchResult[] qualityScores?: QualityScoresMap } @@ -316,20 +315,20 @@ describe('ManualSearchModal.vue', () => { if ( vm.qualityScores && ( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } ).value && typeof ( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } ).value!.set === 'function' ) { ;( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } @@ -340,14 +339,14 @@ describe('ManualSearchModal.vue', () => { if ( vm.qualityScores && typeof ( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } ).set === 'function' ) { ;( - vm.qualityScores as unknown as { + vm.qualityScores as any as { value?: Map set?: (k: string, v: QualityScore) => void } @@ -413,7 +412,7 @@ describe('ManualSearchModal.vue', () => { if (badge.exists() && badge.text().includes('88')) { break } - await new Promise((resolve) => setTimeout(resolve, 25)) + await delay(25) } expect(apiService.getDefaultQualityProfile).toHaveBeenCalledTimes(1) diff --git a/fe/src/__tests__/grabsSortable.spec.ts b/fe/src/components/domain/search/test/grabsSortable.spec.ts similarity index 72% rename from fe/src/__tests__/grabsSortable.spec.ts rename to fe/src/components/domain/search/test/grabsSortable.spec.ts index d3b7b09c8..87c25dcee 100644 --- a/fe/src/__tests__/grabsSortable.spec.ts +++ b/fe/src/components/domain/search/test/grabsSortable.spec.ts @@ -21,92 +21,85 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import ManualSearchModal from '@/components/domain/search/ManualSearchModal.vue' import * as apiModule from '@/services/api' +import { modalStubs } from '@/test/stubs' +import { createIndexer } from '@/test/factories/indexer' +import { createQualityProfile } from '@/test/factories/qualityProfile' +import { createSearchResult } from '@/test/factories/searchResult' +import { delay, waitFor } from '@/test/utils/wait' const { apiService } = apiModule // Ensure instance method exists for legacy spies used in tests -if (!(apiService as unknown).getEnabledIndexers) { - ;(apiService as unknown).getEnabledIndexers = async () => [] +if (!(apiService as any).getEnabledIndexers) { + ;(apiService as any).getEnabledIndexers = async () => [] } -if (!(apiService as unknown).searchByApi) { - ;(apiService as unknown).searchByApi = async () => [] +if (!(apiService as any).searchByApi) { + ;(apiService as any).searchByApi = async () => [] } -if (!(apiService as unknown).getDefaultQualityProfile) { - ;(apiService as unknown).getDefaultQualityProfile = async () => ({ id: 1 }) +if (!(apiService as any).getDefaultQualityProfile) { + ;(apiService as any).getDefaultQualityProfile = async () => ({ id: 1 }) } -if (!(apiService as unknown).scoreSearchResults) { - ;(apiService as unknown).scoreSearchResults = async () => [] +if (!(apiService as any).scoreSearchResults) { + ;(apiService as any).scoreSearchResults = async () => [] } describe('ManualSearchModal - grabs sorting', () => { - const stubs = [ - 'PhMagnifyingGlass', - 'PhX', - 'PhSpinner', - 'PhArrowClockwise', - 'PhArrowUp', - 'PhArrowDown', - 'PhXCircle', - 'PhDownloadSimple', - 'PhArrowsDownUp', - 'ScorePopover', - ] + const stubs = { + PhMagnifyingGlass: true, + PhX: true, + PhSpinner: true, + PhArrowClockwise: true, + PhArrowUp: true, + PhArrowDown: true, + PhXCircle: true, + PhDownloadSimple: true, + PhArrowsDownUp: true, + ScorePopover: { template: '
' }, + ...modalStubs, + } afterEach(() => { vi.restoreAllMocks() }) const triggerSearchAndWait = async (wrapper, selector: string, timeout = 3000) => { - // Manually trigger search then wait for a selector to appear. Increased - // default timeout and ensure a nextTick after starting search so DOM - // updates have a moment to apply in jsdom. try { - await (wrapper.vm as unknown as { search?: () => Promise }).search?.() + await (wrapper.vm as any as { search?: () => Promise }).search?.() } catch {} await nextTick() - const start = Date.now() - while (Date.now() - start < timeout) { - if (wrapper.find(selector).exists()) return - await new Promise((r) => setTimeout(r, 20)) - } - throw new Error('timeout waiting for selector') + await waitFor(() => wrapper.find(selector).exists(), { timeout }) } it('header is clickable to set Grabs sort', async () => { // Mock instance methods on apiService so component calls succeed - vi.spyOn(apiService, 'getEnabledIndexers').mockResolvedValue([ - { id: 1, name: 'Test', implementation: 'Test', additionalSettings: null } as unknown, - ]) + vi.spyOn(apiService, 'getEnabledIndexers').mockResolvedValue([createIndexer()]) vi.spyOn(apiService, 'searchByApi').mockResolvedValue([ - { + createSearchResult({ guid: '1', title: 'A', grabs: 100, - size: 0, publishDate: new Date().toISOString(), indexer: 'Test', indexerId: 1, - } as unknown, - { + }), + createSearchResult({ guid: '2', title: 'B', grabs: 10, - size: 0, publishDate: new Date().toISOString(), indexer: 'Test', indexerId: 1, - } as unknown, - { + }), + createSearchResult({ guid: '3', title: 'C', grabs: 50, - size: 0, publishDate: new Date().toISOString(), indexer: 'Test', indexerId: 1, - } as unknown, - ] as unknown) - vi.spyOn(apiService, 'getDefaultQualityProfile').mockResolvedValue({ id: 1 } as unknown) - vi.spyOn(apiService, 'scoreSearchResults').mockResolvedValue([] as unknown) + }), + ]) + vi.spyOn(apiService, 'getDefaultQualityProfile').mockResolvedValue(createQualityProfile()) + vi.spyOn(apiService, 'scoreSearchResults').mockResolvedValue([]) const wrapper = mount(ManualSearchModal, { props: { isOpen: false, audiobook: { id: 1, title: 'Test', authors: [] } }, @@ -115,7 +108,7 @@ describe('ManualSearchModal - grabs sorting', () => { await wrapper.setProps({ isOpen: true }) // Force the component to run search() in test env and wait for table header - await (wrapper.vm as unknown as { search?: () => Promise }).search?.() + await (wrapper.vm as any as { search?: () => Promise }).search?.() await triggerSearchAndWait(wrapper, 'th.col-grabs') await nextTick() @@ -124,7 +117,7 @@ describe('ManualSearchModal - grabs sorting', () => { // First click: set to Grabs (new column) -> defaults to Descending await header.trigger('click') - await new Promise((resolve) => setTimeout(resolve, 100)) + await delay(100) await nextTick() // Read grabs values from rows in order @@ -138,7 +131,7 @@ describe('ManualSearchModal - grabs sorting', () => { // Second click: same column -> toggles to Ascending await header.trigger('click') - await new Promise((resolve) => setTimeout(resolve, 100)) + await delay(100) await nextTick() const rowsAfterAsc = wrapper.findAll('tbody tr') @@ -152,43 +145,38 @@ describe('ManualSearchModal - grabs sorting', () => { it('header is clickable to set Language sort and toggles order', async () => { // Mock instance methods on apiService so component calls succeed - vi.spyOn(apiService, 'getEnabledIndexers').mockResolvedValue([ - { id: 1, name: 'Test', implementation: 'Test', additionalSettings: null } as unknown, - ]) + vi.spyOn(apiService, 'getEnabledIndexers').mockResolvedValue([createIndexer()]) vi.spyOn(apiService, 'searchByApi').mockResolvedValue([ - { + createSearchResult({ guid: '1', title: 'A', grabs: 0, - size: 0, publishDate: new Date().toISOString(), language: 'Spanish', indexer: 'Test', indexerId: 1, - } as unknown, - { + }), + createSearchResult({ guid: '2', title: 'B', grabs: 0, - size: 0, publishDate: new Date().toISOString(), language: 'English', indexer: 'Test', indexerId: 1, - } as unknown, - { + }), + createSearchResult({ guid: '3', title: 'C', grabs: 0, - size: 0, publishDate: new Date().toISOString(), language: 'French', indexer: 'Test', indexerId: 1, - } as unknown, - ] as unknown) - vi.spyOn(apiService, 'getDefaultQualityProfile').mockResolvedValue({ id: 1 } as unknown) - vi.spyOn(apiService, 'scoreSearchResults').mockResolvedValue([] as unknown) + }), + ]) + vi.spyOn(apiService, 'getDefaultQualityProfile').mockResolvedValue(createQualityProfile()) + vi.spyOn(apiService, 'scoreSearchResults').mockResolvedValue([]) const wrapper = mount(ManualSearchModal, { props: { isOpen: false, audiobook: { id: 1, title: 'Test', authors: [] } }, @@ -197,7 +185,7 @@ describe('ManualSearchModal - grabs sorting', () => { await wrapper.setProps({ isOpen: true }) // Force the component to run search() in test env and wait for table header - await (wrapper.vm as unknown as { search?: () => Promise }).search?.() + await (wrapper.vm as any as { search?: () => Promise }).search?.() await triggerSearchAndWait(wrapper, 'th.col-language') await nextTick() @@ -206,7 +194,7 @@ describe('ManualSearchModal - grabs sorting', () => { // First click: set Language -> defaults to Descending (Z->A) await header.trigger('click') - await new Promise((resolve) => setTimeout(resolve, 100)) + await delay(100) await nextTick() const rowsDesc = wrapper.findAll('tbody tr') @@ -216,7 +204,7 @@ describe('ManualSearchModal - grabs sorting', () => { // Second click toggles to Ascending await header.trigger('click') - await new Promise((resolve) => setTimeout(resolve, 100)) + await delay(100) await nextTick() const rowsAsc = wrapper.findAll('tbody tr') diff --git a/fe/src/__tests__/ConfirmModal.spec.ts b/fe/src/components/feedback/test/ConfirmModal.spec.ts similarity index 100% rename from fe/src/__tests__/ConfirmModal.spec.ts rename to fe/src/components/feedback/test/ConfirmModal.spec.ts diff --git a/fe/src/__tests__/ModalForm.spec.ts b/fe/src/components/feedback/test/ModalForm.spec.ts similarity index 100% rename from fe/src/__tests__/ModalForm.spec.ts rename to fe/src/components/feedback/test/ModalForm.spec.ts diff --git a/fe/src/__tests__/ModalHeader.spec.ts b/fe/src/components/feedback/test/ModalHeader.spec.ts similarity index 100% rename from fe/src/__tests__/ModalHeader.spec.ts rename to fe/src/components/feedback/test/ModalHeader.spec.ts diff --git a/fe/src/__tests__/PasswordInput.spec.ts b/fe/src/components/form/test/PasswordInput.spec.ts similarity index 100% rename from fe/src/__tests__/PasswordInput.spec.ts rename to fe/src/components/form/test/PasswordInput.spec.ts diff --git a/fe/src/__tests__/SearchResultActions.spec.ts b/fe/src/components/search/test/SearchResultActions.spec.ts similarity index 100% rename from fe/src/__tests__/SearchResultActions.spec.ts rename to fe/src/components/search/test/SearchResultActions.spec.ts diff --git a/fe/src/__tests__/SearchResultCard.spec.ts b/fe/src/components/search/test/SearchResultCard.spec.ts similarity index 99% rename from fe/src/__tests__/SearchResultCard.spec.ts rename to fe/src/components/search/test/SearchResultCard.spec.ts index 38873afb1..ac3beaf35 100644 --- a/fe/src/__tests__/SearchResultCard.spec.ts +++ b/fe/src/components/search/test/SearchResultCard.spec.ts @@ -353,7 +353,7 @@ describe('SearchResultCard', () => { title: 'Test Book', author_name: ['Author'], key: 'OL123M', - } as unknown, + } as any, }, }) diff --git a/fe/src/__tests__/SearchResultMetadata.spec.ts b/fe/src/components/search/test/SearchResultMetadata.spec.ts similarity index 100% rename from fe/src/__tests__/SearchResultMetadata.spec.ts rename to fe/src/components/search/test/SearchResultMetadata.spec.ts diff --git a/fe/src/__tests__/AuthenticationSection.spec.ts b/fe/src/components/settings/test/AuthenticationSection.spec.ts similarity index 100% rename from fe/src/__tests__/AuthenticationSection.spec.ts rename to fe/src/components/settings/test/AuthenticationSection.spec.ts diff --git a/fe/src/__tests__/DownloadSettingsSection.spec.ts b/fe/src/components/settings/test/DownloadSettingsSection.spec.ts similarity index 100% rename from fe/src/__tests__/DownloadSettingsSection.spec.ts rename to fe/src/components/settings/test/DownloadSettingsSection.spec.ts diff --git a/fe/src/__tests__/ExternalRequestsSection.spec.ts b/fe/src/components/settings/test/ExternalRequestsSection.spec.ts similarity index 100% rename from fe/src/__tests__/ExternalRequestsSection.spec.ts rename to fe/src/components/settings/test/ExternalRequestsSection.spec.ts diff --git a/fe/src/__tests__/FeaturesSection.spec.ts b/fe/src/components/settings/test/FeaturesSection.spec.ts similarity index 100% rename from fe/src/__tests__/FeaturesSection.spec.ts rename to fe/src/components/settings/test/FeaturesSection.spec.ts diff --git a/fe/src/__tests__/FileManagementSection.spec.ts b/fe/src/components/settings/test/FileManagementSection.spec.ts similarity index 100% rename from fe/src/__tests__/FileManagementSection.spec.ts rename to fe/src/components/settings/test/FileManagementSection.spec.ts diff --git a/fe/src/__tests__/IndexerFormModal.spec.ts b/fe/src/components/settings/test/IndexerFormModal.spec.ts similarity index 98% rename from fe/src/__tests__/IndexerFormModal.spec.ts rename to fe/src/components/settings/test/IndexerFormModal.spec.ts index 047bd2654..b7bbf0365 100644 --- a/fe/src/__tests__/IndexerFormModal.spec.ts +++ b/fe/src/components/settings/test/IndexerFormModal.spec.ts @@ -34,7 +34,7 @@ describe('IndexerFormModal', () => { implementation: 'Newznab', url: 'https://example.test', apiKey: 'secret', - } as unknown, + } as any, }) await wrapper.vm.$nextTick() diff --git a/fe/src/__tests__/RootFoldersSettings.spec.ts b/fe/src/components/settings/test/RootFoldersSettings.spec.ts similarity index 85% rename from fe/src/__tests__/RootFoldersSettings.spec.ts rename to fe/src/components/settings/test/RootFoldersSettings.spec.ts index 65a3e2fce..6b6476788 100644 --- a/fe/src/__tests__/RootFoldersSettings.spec.ts +++ b/fe/src/components/settings/test/RootFoldersSettings.spec.ts @@ -20,6 +20,7 @@ import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import RootFoldersSettings from '@/components/settings/RootFoldersSettings.vue' import { useRootFoldersStore } from '@/stores/rootFolders' +import { createDeferred, flushAsync } from '@/test/utils/wait' describe('RootFoldersSettings', () => { it('shows header spinner and loading state when store.loading is true', async () => { @@ -30,13 +31,10 @@ describe('RootFoldersSettings', () => { // Make the underlying API call pending so store.loading remains true while mounted const api = await import('@/services/api') - let resolveFn: (value: unknown) => void = () => {} + const pendingFolders = createDeferred() // spy on the apiService instance method (module-level named export is not present in TS types) - vi.spyOn((api as unknown).apiService, 'getRootFolders').mockImplementation( - () => - new Promise((res) => { - resolveFn = res - }) as unknown, + vi.spyOn((api as any).apiService, 'getRootFolders').mockImplementation( + () => pendingFolders.promise as any, ) const wrapper = mount(RootFoldersSettings, { global: { plugins: [pinia] } }) @@ -47,8 +45,7 @@ describe('RootFoldersSettings', () => { expect(wrapper.find('.section-header .small-inline-spinner').exists()).toBe(true) // Resolve API and ensure UI updates - resolveFn([]) - await new Promise((r) => setTimeout(r, 0)) - await wrapper.vm.$nextTick() + pendingFolders.resolve([]) + await flushAsync() }) }) diff --git a/fe/src/__tests__/SearchSettingsSection.spec.ts b/fe/src/components/settings/test/SearchSettingsSection.spec.ts similarity index 100% rename from fe/src/__tests__/SearchSettingsSection.spec.ts rename to fe/src/components/settings/test/SearchSettingsSection.spec.ts diff --git a/fe/src/__tests__/ApiKeyControl.spec.ts b/fe/src/components/ui/test/ApiKeyControl.spec.ts similarity index 83% rename from fe/src/__tests__/ApiKeyControl.spec.ts rename to fe/src/components/ui/test/ApiKeyControl.spec.ts index 0c1d99767..5f8aace92 100644 --- a/fe/src/__tests__/ApiKeyControl.spec.ts +++ b/fe/src/components/ui/test/ApiKeyControl.spec.ts @@ -18,6 +18,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import PasswordInput from '@/components/form/PasswordInput.vue' +import { flushAsync } from '@/test/utils/wait' describe('ApiKeyControl', () => { beforeEach(async () => { @@ -28,8 +29,7 @@ describe('ApiKeyControl', () => { it('copies to clipboard when copy button clicked', async () => { const writeMock = vi.fn().mockResolvedValue(undefined) - // @ts-expect-error - provide fake clipboard - global.navigator = { clipboard: { writeText: writeMock } } as unknown + global.navigator = { clipboard: { writeText: writeMock } } as any const { default: ApiKeyControl } = await import('@/components/ui/ApiKeyControl.vue') const wrapper = mount(ApiKeyControl, { @@ -46,11 +46,10 @@ describe('ApiKeyControl', () => { it('regenerates key and emits update when confirmed', async () => { const writeMock = vi.fn().mockResolvedValue(undefined) - // @ts-expect-error - override navigator clipboard in jsdom test environment - global.navigator = { clipboard: { writeText: writeMock } } as unknown + global.navigator = { clipboard: { writeText: writeMock } } as any const confirmModule = await import('@/composables/useConfirm') - vi.spyOn(confirmModule, 'showConfirm').mockResolvedValue(true as unknown) + vi.spyOn(confirmModule, 'showConfirm').mockResolvedValue(true as any) // Mock the api module for this test to return a new key on regenerate vi.doMock('@/services/api', () => ({ apiService: { @@ -66,16 +65,16 @@ describe('ApiKeyControl', () => { }) // Call the internal handler directly to avoid DOM-event quirks in VTU - const setupState = (wrapper.vm as unknown).$?.setupState || (wrapper.vm as unknown).$setup + const setupState = (wrapper.vm as any).$?.setupState || (wrapper.vm as any).$setup await (setupState.onRegenerate as () => Promise)() // wait for async handlers and promise resolution - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Ensure underlying API was called const apiModule = await import('@/services/api') - expect((apiModule.apiService.regenerateApiKey as unknown).mock).toBeTruthy() - expect((apiModule.apiService.regenerateApiKey as unknown).mock.calls.length).toBeGreaterThan(0) + expect((apiModule.apiService.regenerateApiKey as any).mock).toBeTruthy() + expect((apiModule.apiService.regenerateApiKey as any).mock.calls.length).toBeGreaterThan(0) // Should emit update:apiKey with new key expect(wrapper.emitted()['update:apiKey']).toBeTruthy() @@ -86,11 +85,10 @@ describe('ApiKeyControl', () => { it('generates initial key when none exists', async () => { const writeMock = vi.fn().mockResolvedValue(undefined) - // @ts-expect-error - override navigator clipboard in jsdom test environment - global.navigator = { clipboard: { writeText: writeMock } } as unknown + global.navigator = { clipboard: { writeText: writeMock } } as any const confirmModule = await import('@/composables/useConfirm') - vi.spyOn(confirmModule, 'showConfirm').mockResolvedValue(true as unknown) + vi.spyOn(confirmModule, 'showConfirm').mockResolvedValue(true as any) // Mock generateInitialApiKey to return a new key for initial generation vi.doMock('@/services/api', () => ({ apiService: { @@ -107,14 +105,12 @@ describe('ApiKeyControl', () => { const regenBtn = wrapper.find('button.regen-btn') await regenBtn.trigger('click') - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Ensure underlying API was called const apiModule = await import('@/services/api') - expect((apiModule.apiService.generateInitialApiKey as unknown).mock).toBeTruthy() - expect( - (apiModule.apiService.generateInitialApiKey as unknown).mock.calls.length, - ).toBeGreaterThan(0) + expect((apiModule.apiService.generateInitialApiKey as any).mock).toBeTruthy() + expect((apiModule.apiService.generateInitialApiKey as any).mock.calls.length).toBeGreaterThan(0) expect(wrapper.emitted()['update:apiKey']).toBeTruthy() expect(wrapper.emitted()['update:apiKey']![0]).toEqual(['INITKEY']) diff --git a/fe/src/__tests__/useAdvancedSearch.spec.ts b/fe/src/composables/test/useAdvancedSearch.spec.ts similarity index 100% rename from fe/src/__tests__/useAdvancedSearch.spec.ts rename to fe/src/composables/test/useAdvancedSearch.spec.ts diff --git a/fe/src/__tests__/useScore.spec.ts b/fe/src/composables/test/useScore.node.spec.ts similarity index 79% rename from fe/src/__tests__/useScore.spec.ts rename to fe/src/composables/test/useScore.node.spec.ts index e8a76952b..adf323f49 100644 --- a/fe/src/__tests__/useScore.spec.ts +++ b/fe/src/composables/test/useScore.node.spec.ts @@ -17,28 +17,13 @@ */ import { describe, it, expect } from 'vitest' import { getScoreBreakdownTooltip } from '@/composables/useScore' -import type { QualityScore, SearchResult } from '@/types' +import type { QualityScore } from '@/types' +import { createSearchResult } from '@/test/factories/searchResult' describe('useScore composable', () => { it('includes Smart composite breakdown when provided', () => { - const fakeResult = { - id: 'r1', - title: 'T', - artist: '', - album: '', - category: '', - source: '', - publishedDate: '', - format: '', - size: 0, - magnetLink: '', - torrentUrl: '', - nzbUrl: '', - downloadType: '', - quality: '', - } as unknown as SearchResult const score: QualityScore = { - searchResult: fakeResult, + searchResult: createSearchResult({ id: 'r1', title: 'T' }), totalScore: 100, scoreBreakdown: { Quality: 90 }, rejectionReasons: [], diff --git a/fe/src/__tests__/useScore.rejection.spec.ts b/fe/src/composables/test/useScore.rejection.node.spec.ts similarity index 75% rename from fe/src/__tests__/useScore.rejection.spec.ts rename to fe/src/composables/test/useScore.rejection.node.spec.ts index e10c2d665..ea976b53d 100644 --- a/fe/src/__tests__/useScore.rejection.spec.ts +++ b/fe/src/composables/test/useScore.rejection.node.spec.ts @@ -17,28 +17,13 @@ */ import { describe, it, expect } from 'vitest' import { getScoreBreakdownTooltip } from '@/composables/useScore' -import type { QualityScore, SearchResult } from '@/types' +import type { QualityScore } from '@/types' +import { createSearchResult } from '@/test/factories/searchResult' describe('useScore composable - rejection behavior', () => { it('returns only rejection reason for rejected scores', () => { - const fakeResult = { - id: 'r1', - title: 'T', - artist: '', - album: '', - category: '', - source: '', - publishedDate: '', - format: '', - size: 0, - magnetLink: '', - torrentUrl: '', - nzbUrl: '', - downloadType: '', - quality: '', - } as unknown as SearchResult const score: QualityScore = { - searchResult: fakeResult, + searchResult: createSearchResult({ id: 'r1', title: 'T' }), totalScore: -1, scoreBreakdown: {}, rejectionReasons: ['Low seeders'], diff --git a/fe/src/__tests__/api.advancedSearch.spec.ts b/fe/src/services/test/api.advancedSearch.node.spec.ts similarity index 93% rename from fe/src/__tests__/api.advancedSearch.spec.ts rename to fe/src/services/test/api.advancedSearch.node.spec.ts index 480541612..3543850e6 100644 --- a/fe/src/__tests__/api.advancedSearch.spec.ts +++ b/fe/src/services/test/api.advancedSearch.node.spec.ts @@ -44,7 +44,7 @@ describe('ApiService advancedSearch', () => { }) expect(fetchMock).toHaveBeenCalledTimes(1) - const [, options] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit] + const [, options] = fetchMock.mock.calls[0] as any as [RequestInfo, RequestInit] const body = JSON.parse(String(options.body)) expect(body).toEqual({ @@ -76,7 +76,7 @@ describe('ApiService advancedSearch', () => { }) expect(fetchMock).toHaveBeenCalledTimes(1) - const [, options] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit] + const [, options] = fetchMock.mock.calls[0] as any as [RequestInfo, RequestInit] const body = JSON.parse(String(options.body)) expect(body).toEqual({ diff --git a/fe/src/__tests__/api.csrf-retry.spec.ts b/fe/src/services/test/api.csrf-retry.node.spec.ts similarity index 96% rename from fe/src/__tests__/api.csrf-retry.spec.ts rename to fe/src/services/test/api.csrf-retry.node.spec.ts index 5f162c396..ff95ca35f 100644 --- a/fe/src/__tests__/api.csrf-retry.spec.ts +++ b/fe/src/services/test/api.csrf-retry.node.spec.ts @@ -17,7 +17,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // Import the real ApiService at test time (some tests mock the module globally) -let apiService: unknown +let apiService: { request: (path: string, init?: RequestInit) => Promise } // Spies for toast methods const info = vi.fn() @@ -29,7 +29,7 @@ vi.mock('@/services/toastService', () => ({ })) describe('ApiService CSRF retry', () => { - let fetchMock: unknown + let fetchMock: any beforeEach(() => { info.mockClear() @@ -95,7 +95,7 @@ describe('ApiService CSRF retry', () => { // Import the real ApiService implementation to avoid global mocks const actual = await vi.importActual('@/services/api') - apiService = actual.apiService + apiService = actual.apiService as any // Simulate a request that includes an API key header (like saving the API key) const res = await apiService.request('/some/test', { @@ -107,7 +107,7 @@ describe('ApiService CSRF retry', () => { // Verify the retry request included the refreshed token in headers // Find any fetch call that targeted our test endpoint and had the token header - const calls = (fetchMock as unknown).mock.calls as Array + const calls = fetchMock.mock.calls as Array // Verify the antiforgery token fetch used the original request's API key header const tokenFetchCall = calls.find((c) => { diff --git a/fe/src/__tests__/api.downloadLogs.spec.ts b/fe/src/services/test/api.downloadLogs.spec.ts similarity index 97% rename from fe/src/__tests__/api.downloadLogs.spec.ts rename to fe/src/services/test/api.downloadLogs.spec.ts index ffa1b1f1f..1abc6617d 100644 --- a/fe/src/__tests__/api.downloadLogs.spec.ts +++ b/fe/src/services/test/api.downloadLogs.spec.ts @@ -17,7 +17,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.unmock('../services/api') +vi.unmock('@/services/api') describe('ApiService.downloadLogs', () => { beforeEach(() => { @@ -30,7 +30,7 @@ describe('ApiService.downloadLogs', () => { it('downloads logs through authenticated fetch when session auth is enabled', async () => { vi.resetModules() - const { apiService } = await import('../services/api') + const { apiService } = await import('@/services/api') const { sessionTokenManager } = await import('@/utils/sessionToken') sessionTokenManager.setToken('session-token') diff --git a/fe/src/__tests__/api.ensureImageCached.spec.ts b/fe/src/services/test/api.ensureImageCached.node.spec.ts similarity index 90% rename from fe/src/__tests__/api.ensureImageCached.spec.ts rename to fe/src/services/test/api.ensureImageCached.node.spec.ts index 309fa8b92..667178b3e 100644 --- a/fe/src/__tests__/api.ensureImageCached.spec.ts +++ b/fe/src/services/test/api.ensureImageCached.node.spec.ts @@ -18,9 +18,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { API_BASE_PATH } from '@/services/apiBase' -// Ensure we use the actual implementation (test-setup globally mocks /services/api) -vi.unmock('../services/api') -import { apiService as svc } from '../services/api' +// Ensure we use the actual implementation (test setup globally mocks /services/api) +vi.unmock('@/services/api') +import { apiService as svc } from '@/services/api' type FetchCall = [RequestInfo | URL, RequestInit?] type FetchLikeMock = { mock: { calls: FetchCall[] } } @@ -60,7 +60,7 @@ describe('ApiService.ensureImageCached', () => { const ok = await svc.ensureImageCached(`${imageBasePath}/ASIN000001`) expect(ok).toBe(true) - const fetchCalls = (globalThis.fetch as unknown as FetchLikeMock).mock.calls + const fetchCalls = (globalThis.fetch as any as FetchLikeMock).mock.calls expect(fetchCalls.some((c) => String(c[0]).includes(`${imageBasePath}/ASIN000001?url=`))).toBe( true, ) @@ -81,7 +81,7 @@ describe('ApiService.ensureImageCached', () => { const ok = await svc.ensureImageCached(`${imageBasePath}/ASIN000002`) expect(ok).toBe(true) - const fetchCalls = (globalThis.fetch as unknown as FetchLikeMock).mock.calls + const fetchCalls = (globalThis.fetch as any as FetchLikeMock).mock.calls expect(fetchCalls.some((c) => String(c[0]).endsWith(`${imageBasePath}/ASIN000002`))).toBe(true) }) @@ -108,7 +108,7 @@ describe('ApiService.ensureImageCached', () => { const ok = await svc.ensureImageCached(`${imageBasePath}/ASIN000003`) expect(ok).toBe(false) - const fetchCalls = (globalThis.fetch as unknown as FetchLikeMock).mock.calls + const fetchCalls = (globalThis.fetch as any as FetchLikeMock).mock.calls expect(fetchCalls.some((c) => String(c[0]).includes(`${imageBasePath}/ASIN000003?url=`))).toBe( true, ) diff --git a/fe/src/__tests__/api.removeFromLibrary.spec.ts b/fe/src/services/test/api.removeFromLibrary.node.spec.ts similarity index 94% rename from fe/src/__tests__/api.removeFromLibrary.spec.ts rename to fe/src/services/test/api.removeFromLibrary.node.spec.ts index 5c5f4797e..cec25e1cd 100644 --- a/fe/src/__tests__/api.removeFromLibrary.spec.ts +++ b/fe/src/services/test/api.removeFromLibrary.node.spec.ts @@ -40,7 +40,7 @@ describe('ApiService removeFromLibrary', () => { await actual.apiService.removeFromLibrary(42, { deleteFiles: true, deleteFolder: true }) expect(fetchMock).toHaveBeenCalledTimes(1) - const [requestInfo, options] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit] + const [requestInfo, options] = fetchMock.mock.calls[0] as any as [RequestInfo, RequestInit] expect(String(requestInfo)).toContain('/library/42?deleteFiles=true&deleteFolder=true') expect(options.method).toBe('DELETE') }) diff --git a/fe/src/__tests__/startupConfigCache.test.ts b/fe/src/services/test/startupConfigCache.node.spec.ts similarity index 72% rename from fe/src/__tests__/startupConfigCache.test.ts rename to fe/src/services/test/startupConfigCache.node.spec.ts index 535f1e642..fcecdd9ce 100644 --- a/fe/src/__tests__/startupConfigCache.test.ts +++ b/fe/src/services/test/startupConfigCache.node.spec.ts @@ -18,25 +18,22 @@ import { describe, it, expect, beforeEach } from 'vitest' import * as cache from '@/services/startupConfigCache' import { apiService } from '@/services/api' +import { createDeferred } from '@/test/utils/wait' // Mock apiService.getStartupConfig with a delayed resolver let originalGet: unknown beforeEach(() => { cache.resetCache() - originalGet = (apiService as unknown as { getStartupConfig?: unknown }).getStartupConfig + originalGet = (apiService as any as { getStartupConfig?: unknown }).getStartupConfig }) describe('startupConfigCache', () => { it('deduplicates concurrent calls', async () => { - let resolve: (value: unknown) => void - const p = new Promise((res) => { - resolve = res - }) - ;(apiService as unknown as { getStartupConfig?: () => Promise }).getStartupConfig = - () => { - return p - } + const pendingConfig = createDeferred() + ;(apiService as any as { getStartupConfig?: () => Promise }).getStartupConfig = () => { + return pendingConfig.promise + } // Start multiple concurrent callers const callers = Promise.all([ @@ -45,8 +42,7 @@ describe('startupConfigCache', () => { cache.getStartupConfigCached(), ]) - // let the calls be inflight for a moment - setTimeout(() => resolve({ authenticationRequired: 'Enabled' }), 50) + pendingConfig.resolve({ authenticationRequired: 'Enabled' }) const results = await callers expect(results.length).toBe(3) @@ -56,7 +52,7 @@ describe('startupConfigCache', () => { }) // restore -const restore = originalGet as unknown +const restore = originalGet as any if (restore) { - ;(apiService as unknown as { getStartupConfig?: unknown }).getStartupConfig = restore + ;(apiService as any as { getStartupConfig?: unknown }).getStartupConfig = restore } diff --git a/fe/src/__tests__/audiobook-update-merge.spec.ts b/fe/src/stores/test/audiobook-update-merge.spec.ts similarity index 79% rename from fe/src/__tests__/audiobook-update-merge.spec.ts rename to fe/src/stores/test/audiobook-update-merge.spec.ts index a6bddf756..41b0c161e 100644 --- a/fe/src/__tests__/audiobook-update-merge.spec.ts +++ b/fe/src/stores/test/audiobook-update-merge.spec.ts @@ -16,25 +16,18 @@ * along with this program. If not, see . */ import { setActivePinia, createPinia } from 'pinia' -import { useLibraryStore } from '@/stores/library' import { describe, test, expect, beforeEach, vi } from 'vitest' -import { signalRService } from '@/services/signalr' +import { useLibraryStore } from '@/stores/library' +import { signalRServiceMock } from '@/test/mocks/signalr' import type { Audiobook } from '@/types' describe('AudiobookUpdate SignalR merge', () => { beforeEach(() => { setActivePinia(createPinia()) + vi.clearAllMocks() }) - test('merges server-provided audiobook DTO into store item', async () => { - const callbacks: Array<(a: Audiobook) => void> = [] - const spy = vi - .spyOn(signalRService, 'onAudiobookUpdate') - .mockImplementation((cb?: (...args: unknown[]) => void) => { - if (cb) callbacks.push(cb as (a: Audiobook) => void) - return () => {} - }) - + test('merges server-provided audiobook DTO into store item', () => { const store = useLibraryStore() store.audiobooks = [ { @@ -54,8 +47,8 @@ describe('AudiobookUpdate SignalR merge', () => { } // Call the registered callback - expect(callbacks.length).toBeGreaterThan(0) - callbacks[0](serverDto as Audiobook) + expect(signalRServiceMock.callbacks.audiobookUpdate.size).toBeGreaterThan(0) + signalRServiceMock.emit('audiobookUpdate', serverDto) // Assert store was merged correctly const merged = store.audiobooks.find((b) => b.id === 1) as Audiobook @@ -65,7 +58,5 @@ describe('AudiobookUpdate SignalR merge', () => { // Files replaced since server provided non-empty array expect(merged.files).toHaveLength(1) expect(merged.files![0].path).toBe('/new/path/file.m4b') - - spy.mockRestore() }) }) diff --git a/fe/src/__tests__/downloads.store.spec.ts b/fe/src/stores/test/downloads.store.spec.ts similarity index 100% rename from fe/src/__tests__/downloads.store.spec.ts rename to fe/src/stores/test/downloads.store.spec.ts diff --git a/fe/src/__tests__/library-fetch.spec.ts b/fe/src/stores/test/library-fetch.spec.ts similarity index 100% rename from fe/src/__tests__/library-fetch.spec.ts rename to fe/src/stores/test/library-fetch.spec.ts diff --git a/fe/src/__tests__/library.spec.ts b/fe/src/stores/test/library.spec.ts similarity index 100% rename from fe/src/__tests__/library.spec.ts rename to fe/src/stores/test/library.spec.ts diff --git a/fe/src/__tests__/libraryImport.store.spec.ts b/fe/src/stores/test/libraryImport.store.spec.ts similarity index 98% rename from fe/src/__tests__/libraryImport.store.spec.ts rename to fe/src/stores/test/libraryImport.store.spec.ts index b8bdad1eb..62543864f 100644 --- a/fe/src/__tests__/libraryImport.store.spec.ts +++ b/fe/src/stores/test/libraryImport.store.spec.ts @@ -18,6 +18,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import type { SearchResult } from '@/types' +import { flushAsync } from '@/test/utils/wait' const startManualImport = vi.fn() const addToLibrary = vi.fn() @@ -90,7 +91,7 @@ describe('library import store', () => { selectedMatch: { title: 'Ordered Book', authors: [], - } as unknown as SearchResult, + } as any as SearchResult, hasSearched: true, isSearching: false, selected: true, @@ -185,7 +186,7 @@ describe('library import store', () => { } store.startProcessing() - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() expect(advancedSearch).toHaveBeenCalledWith({ title: 'Jack of Shadows', diff --git a/fe/src/test/README.md b/fe/src/test/README.md new file mode 100644 index 000000000..3f83d0366 --- /dev/null +++ b/fe/src/test/README.md @@ -0,0 +1,30 @@ +# Frontend Test Layout + +Vitest specs live in `test/` folders next to the code they exercise: + +- Components: `src/components//test/*.spec.ts` +- Views: `src/views//test/*.spec.ts` +- Stores, services, composables, and utilities: `src//test/*.spec.ts` + +Use `*.spec.ts` for frontend tests; Vitest only discovers that extension under +`src/**/test/`. Specs run in jsdom by default. Use `*.node.spec.ts` only for +tests that intentionally run without browser globals. Test infrastructure in +`src/test` is opt-in only: factories, explicit mocks, local stubs, and mount +helpers. Shared specs under `src/test` are limited to app-shell, framework, and +smoke coverage. + +Rules: + +- Keep Vitest setup files small and side-effect focused. +- The only global app-service mock is `@/services/signalr`, because the real + singleton auto-connects on import and leaks WebSocket/timer work into + unrelated tests. +- Do not add global browser/API monkeypatches. +- Put API, toast, storage, and component stubs directly in the spec that needs + them, or import explicit helpers from `src/test`. +- Use `src/test/mocks/signalr` when a spec needs to inspect or emit SignalR + callbacks from the global mock. +- Keep test data in factories when the same shape appears in multiple specs. +- Run `npm run type-check:test` before changing shared helpers or fixture + factories. +- Run `npm run verify` before submitting frontend test infrastructure changes. diff --git a/fe/src/__tests__/AppActivityBadge.spec.ts b/fe/src/test/app/AppActivityBadge.spec.ts similarity index 94% rename from fe/src/__tests__/AppActivityBadge.spec.ts rename to fe/src/test/app/AppActivityBadge.spec.ts index 9fcc7f468..0dcea729b 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/test/app/AppActivityBadge.spec.ts @@ -19,6 +19,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { computed, ref } from 'vue' import { createPinia, setActivePinia } from 'pinia' +import { delay } from '@/test/utils/wait' // Mock the downloads store so App.vue picks up the activeDownloads correctly vi.mock('@/stores/downloads', () => ({ @@ -72,7 +73,7 @@ describe('App.vue activity badge', () => { }) // Ensure localStorage APIs exist in the test environment for App.vue session debug helpers - if (typeof (globalThis as unknown as { localStorage?: unknown }).localStorage === 'undefined') { + if (typeof (globalThis as any as { localStorage?: unknown }).localStorage === 'undefined') { Object.defineProperty(globalThis, 'localStorage', { value: { _store: {} as Record, @@ -135,9 +136,9 @@ describe('App.vue activity badge', () => { // Wait a tick for computed properties in mounted hook // Allow async onMounted tasks (SignalR/connect, api fetches) to settle - await new Promise((r) => setTimeout(r, 20)) + await delay(20) - const vm = wrapper.vm as unknown as { activityCount: number } + const vm = wrapper.vm as any as { activityCount: number } // The badge should reflect the single active DDL download expect(vm.activityCount).toBe(1) }, 20000) @@ -183,9 +184,9 @@ describe('App.vue activity badge', () => { }) // Allow async onMounted tasks to settle - await new Promise((r) => setTimeout(r, 20)) + await delay(20) - const vm = wrapper.vm as unknown as { activityCount: number } + const vm = wrapper.vm as any as { activityCount: number } expect(vm.activityCount).toBe(1) }) @@ -246,9 +247,9 @@ describe('App.vue activity badge', () => { }) // Allow async onMounted tasks (SignalR/connect, api fetches) to settle - await new Promise((r) => setTimeout(r, 20)) + await delay(20) - const vm = wrapper.vm as unknown as { activityCount: number } + const vm = wrapper.vm as any as { activityCount: number } // With zero active downloads and two queue items, activityCount should reflect the queue expect(vm.activityCount).toBe(2) }, 20000) @@ -281,9 +282,9 @@ describe('App.vue activity badge', () => { global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) - await new Promise((r) => setTimeout(r, 20)) + await delay(20) - const vm = wrapper.vm as unknown as { wantedCount: number } + const vm = wrapper.vm as any as { wantedCount: number } expect(vm.wantedCount).toBe(1) expect(setIntervalSpy).not.toHaveBeenCalled() @@ -335,15 +336,15 @@ describe('App.vue activity badge', () => { global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) - await new Promise((r) => setTimeout(r, 20)) + await delay(20) expect(getLibrary).toHaveBeenCalledTimes(1) expect(connectedCallbacks).toHaveLength(1) connectedCallbacks[0]!() - await new Promise((r) => setTimeout(r, 20)) + await delay(20) - const vm = wrapper.vm as unknown as { wantedCount: number } + const vm = wrapper.vm as any as { wantedCount: number } expect(getLibrary).toHaveBeenCalledTimes(2) expect(vm.wantedCount).toBe(1) }) diff --git a/fe/src/test/factories/audiobook.ts b/fe/src/test/factories/audiobook.ts new file mode 100644 index 000000000..626a6f223 --- /dev/null +++ b/fe/src/test/factories/audiobook.ts @@ -0,0 +1,14 @@ +import type { Audiobook } from '@/types' + +export function createAudiobook(overrides: Partial = {}): Audiobook { + return { + id: 1, + title: 'Test Book', + authors: ['Test Author'], + narrators: [], + files: [], + monitored: true, + tags: [], + ...overrides, + } as Audiobook +} diff --git a/fe/src/test/factories/download.ts b/fe/src/test/factories/download.ts new file mode 100644 index 000000000..2426eef4e --- /dev/null +++ b/fe/src/test/factories/download.ts @@ -0,0 +1,11 @@ +import type { Download } from '@/types' + +export function createDownload(overrides: Partial = {}): Download { + return { + id: 'download-1', + title: 'Test Download', + status: 'Downloading', + downloadClientId: 'client-1', + ...overrides, + } as Download +} diff --git a/fe/src/test/factories/downloadClient.ts b/fe/src/test/factories/downloadClient.ts new file mode 100644 index 000000000..c9a124df3 --- /dev/null +++ b/fe/src/test/factories/downloadClient.ts @@ -0,0 +1,20 @@ +import type { DownloadClientConfiguration } from '@/types' + +export function createDownloadClientConfiguration( + overrides: Partial = {}, +): DownloadClientConfiguration { + return { + id: 'client-1', + name: 'Test Client', + type: 'qbittorrent', + host: 'localhost', + port: 8080, + username: '', + password: '', + downloadPath: '', + useSSL: false, + isEnabled: true, + settings: {}, + ...overrides, + } +} diff --git a/fe/src/test/factories/indexer.ts b/fe/src/test/factories/indexer.ts new file mode 100644 index 000000000..4b1cffc68 --- /dev/null +++ b/fe/src/test/factories/indexer.ts @@ -0,0 +1,28 @@ +import type { Indexer } from '@/types' + +export function createIndexer(overrides: Partial = {}): Indexer { + return { + id: 1, + name: 'Test Indexer', + type: 'Torrent', + implementation: 'Torznab', + url: 'https://indexer.example', + apiKey: '', + categories: '', + animeCategories: '', + tags: '', + enableRss: true, + enableAutomaticSearch: true, + enableInteractiveSearch: true, + enableAnimeStandardSearch: false, + isEnabled: true, + priority: 25, + minimumAge: 0, + retention: 0, + maximumSize: 0, + additionalSettings: '', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} diff --git a/fe/src/test/factories/qualityProfile.ts b/fe/src/test/factories/qualityProfile.ts new file mode 100644 index 000000000..c084445b7 --- /dev/null +++ b/fe/src/test/factories/qualityProfile.ts @@ -0,0 +1,11 @@ +import type { QualityProfile } from '@/types' + +export function createQualityProfile(overrides: Partial = {}): QualityProfile { + return { + id: 1, + name: 'Any', + cutoff: 'Any', + allowedFormats: [], + ...overrides, + } as QualityProfile +} diff --git a/fe/src/test/factories/rootFolder.ts b/fe/src/test/factories/rootFolder.ts new file mode 100644 index 000000000..a49f64a16 --- /dev/null +++ b/fe/src/test/factories/rootFolder.ts @@ -0,0 +1,10 @@ +import type { RootFolder } from '@/types' + +export function createRootFolder(overrides: Partial = {}): RootFolder { + return { + id: 1, + path: 'C:\\Books', + name: 'Books', + ...overrides, + } as RootFolder +} diff --git a/fe/src/test/factories/searchResult.ts b/fe/src/test/factories/searchResult.ts new file mode 100644 index 000000000..26d44a66a --- /dev/null +++ b/fe/src/test/factories/searchResult.ts @@ -0,0 +1,29 @@ +import type { SearchResult } from '@/types' + +export function createSearchResult( + overrides: Partial & Record = {}, +): SearchResult { + return { + id: 'result-1', + title: 'Test Result', + artist: 'Test Author', + album: 'Test Result', + category: 'Audiobook', + source: 'Test Indexer', + publishedDate: '2026-01-01T00:00:00.000Z', + format: 'MP3', + author: 'Test Author', + authors: ['Test Author'], + narrators: [], + asin: 'B000000001', + size: 0, + magnetLink: '', + torrentUrl: '', + nzbUrl: '', + downloadType: 'Torrent', + quality: 'MP3', + imageUrl: '', + metadataSource: 'Audible', + ...overrides, + } as SearchResult +} diff --git a/fe/src/test/factories/settings.ts b/fe/src/test/factories/settings.ts new file mode 100644 index 000000000..0953c9360 --- /dev/null +++ b/fe/src/test/factories/settings.ts @@ -0,0 +1,10 @@ +import type { ApplicationSettings } from '@/types' + +export function createApplicationSettings( + overrides: Partial = {}, +): ApplicationSettings { + return { + outputPath: 'C:\\Books', + ...overrides, + } as ApplicationSettings +} diff --git a/fe/src/test/mocks/api.ts b/fe/src/test/mocks/api.ts new file mode 100644 index 000000000..22a4ec5d4 --- /dev/null +++ b/fe/src/test/mocks/api.ts @@ -0,0 +1,46 @@ +import { vi } from 'vitest' + +type ApiMockOverrides = Record + +export function createApiServiceMock( + overrides: TOverrides = {} as TOverrides, +) { + const apiService = { + searchAudibleByTitleAndAuthor: vi.fn(async () => ({ totalResults: 0, results: [] })), + advancedSearch: vi.fn(async () => ({ totalResults: 0, results: [] })), + getImageUrl: vi.fn((url: string) => url || ''), + getStartupConfig: vi.fn(async () => ({})), + getApplicationSettings: vi.fn(async () => ({})), + getLibrary: vi.fn(async () => []), + previewLibraryPath: vi.fn(async () => ({ fullPath: '', relativePath: '' })), + previewRename: vi.fn(async () => []), + executeRename: vi.fn(async () => []), + getQualityProfiles: vi.fn(async () => []), + getApiConfigurations: vi.fn(async () => []), + getRootFolders: vi.fn(async () => []), + checkVolume: vi.fn(async () => ({ sameVolume: true, willBreakHardlinks: false })), + ...overrides, + } + + return apiService as typeof apiService & TOverrides +} + +export function createApiModuleMock( + overrides: TOverrides = {} as TOverrides, +) { + const apiService = createApiServiceMock(overrides) + + return { + apiService, + getRemotePathMappings: vi.fn(async () => []), + testDownloadClient: vi.fn(async () => ({ success: true, message: 'ok' })), + ensureImageCached: vi.fn(async (url: string) => url || ''), + getLogs: vi.fn(async () => []), + downloadLogs: vi.fn(async () => null), + getRootFolders: vi.fn(async () => []), + getQualityProfiles: vi.fn(async () => []), + getStartupConfig: vi.fn(async () => ({})), + getApplicationSettings: vi.fn(async () => ({})), + checkVolume: vi.fn(async () => ({ sameVolume: true, willBreakHardlinks: false })), + } +} diff --git a/fe/src/test/mocks/signalr.ts b/fe/src/test/mocks/signalr.ts new file mode 100644 index 000000000..1a8d3ad9a --- /dev/null +++ b/fe/src/test/mocks/signalr.ts @@ -0,0 +1,94 @@ +import { vi } from 'vitest' + +type SignalRCallback = (...args: unknown[]) => void + +export const signalREvents = [ + 'connected', + 'disconnected', + 'downloadUpdate', + 'downloadsList', + 'queueUpdate', + 'audiobookUpdate', + 'scanJobUpdate', + 'moveJobUpdate', + 'searchProgress', + 'toast', + 'notification', + 'indexersUpdated', + 'unmatchedScanComplete', + 'filesRemoved', +] as const + +export type SignalREvent = (typeof signalREvents)[number] + +export type SignalRCallbacks = Record> + +function createCallbackRegistry(): SignalRCallbacks { + return Object.fromEntries(signalREvents.map((event) => [event, new Set()])) as SignalRCallbacks +} + +export function createSignalRServiceMock(overrides: Record = {}) { + const callbacks = createCallbackRegistry() + const subscribe = (event: SignalREvent, callback?: SignalRCallback) => { + if (callback) callbacks[event].add(callback) + return () => { + if (callback) callbacks[event].delete(callback) + } + } + + const signalRService = { + connect: vi.fn(async () => undefined), + connectSettings: vi.fn(async () => undefined), + disconnect: vi.fn(() => undefined), + requestDownloadsUpdate: vi.fn(() => undefined), + isConnected: false, + onConnected: vi.fn((callback?: SignalRCallback) => subscribe('connected', callback)), + onDisconnected: vi.fn((callback?: SignalRCallback) => subscribe('disconnected', callback)), + onDownloadsList: vi.fn((callback?: SignalRCallback) => subscribe('downloadsList', callback)), + onSearchProgress: vi.fn((callback?: SignalRCallback) => subscribe('searchProgress', callback)), + onQueueUpdate: vi.fn((callback?: SignalRCallback) => subscribe('queueUpdate', callback)), + onDownloadUpdate: vi.fn((callback?: SignalRCallback) => subscribe('downloadUpdate', callback)), + onFilesRemoved: vi.fn((callback?: SignalRCallback) => subscribe('filesRemoved', callback)), + onAudiobookUpdate: vi.fn((callback?: SignalRCallback) => + subscribe('audiobookUpdate', callback), + ), + onNotification: vi.fn((callback?: SignalRCallback) => subscribe('notification', callback)), + onToast: vi.fn((callback?: SignalRCallback) => subscribe('toast', callback)), + onMoveJobUpdate: vi.fn((callback?: SignalRCallback) => subscribe('moveJobUpdate', callback)), + onScanJobUpdate: vi.fn((callback?: SignalRCallback) => subscribe('scanJobUpdate', callback)), + onIndexersUpdated: vi.fn((callback?: SignalRCallback) => + subscribe('indexersUpdated', callback), + ), + onUnmatchedScanComplete: vi.fn((callback?: SignalRCallback) => + subscribe('unmatchedScanComplete', callback), + ), + ...overrides, + } + + return { + callbacks, + signalRService, + emit(event: SignalREvent, ...args: unknown[]) { + for (const callback of callbacks[event]) { + callback(...args) + } + }, + reset() { + for (const callbackSet of Object.values(callbacks)) { + callbackSet.clear() + } + for (const value of Object.values(signalRService)) { + if (vi.isMockFunction(value)) { + value.mockClear() + } + } + signalRService.isConnected = false + }, + } +} + +export const signalRServiceMock = createSignalRServiceMock() + +export function resetSignalRServiceMock() { + signalRServiceMock.reset() +} diff --git a/fe/src/test/setup/signalr.ts b/fe/src/test/setup/signalr.ts new file mode 100644 index 000000000..91c12c478 --- /dev/null +++ b/fe/src/test/setup/signalr.ts @@ -0,0 +1,15 @@ +import { afterEach, vi } from 'vitest' + +vi.mock('@/services/signalr', async () => { + const { signalRServiceMock } = await import('@/test/mocks/signalr') + + return { + signalRService: signalRServiceMock.signalRService, + } +}) + +afterEach(async () => { + const { resetSignalRServiceMock } = await import('@/test/mocks/signalr') + + resetSignalRServiceMock() +}) diff --git a/fe/src/__tests__/sanity.spec.ts b/fe/src/test/smoke/sanity.spec.ts similarity index 100% rename from fe/src/__tests__/sanity.spec.ts rename to fe/src/test/smoke/sanity.spec.ts diff --git a/fe/src/test/stubs.ts b/fe/src/test/stubs.ts new file mode 100644 index 000000000..36cf168f7 --- /dev/null +++ b/fe/src/test/stubs.ts @@ -0,0 +1,75 @@ +import type { Component } from 'vue' + +export const modalStubs: Record = { + Modal: { + emits: ['close'], + props: ['visible', 'title', 'showClose', 'size'], + template: + '
', + mounted() { + this._onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.$emit?.('close') + } + document.addEventListener('keydown', this._onKey) + }, + unmounted() { + if (this._onKey) document.removeEventListener('keydown', this._onKey) + }, + }, + BaseModal: { + emits: ['close'], + props: ['visible', 'title', 'showClose', 'size'], + template: + '
', + mounted() { + this._onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.$emit?.('close') + } + document.addEventListener('keydown', this._onKey) + }, + unmounted() { + if (this._onKey) document.removeEventListener('keydown', this._onKey) + }, + }, + ModalHeader: { + props: ['title', 'icon', 'iconLabel'], + emits: ['close'], + template: + '', + }, + ModalBody: { + template: '', + }, + ModalFooter: { + template: '', + }, + ModalForm: { + template: '
', + }, + ModalActions: { + template: '', + }, + ModalSpinnerOverlay: { + template: '', + }, +} + +export const baseStubs: Record = { + BrandLogo: { + template: '
', + }, + LoadingState: { + props: ['message', 'size'], + template: + '

{{ message }}

', + }, + PhSpinner: { + props: ['size'], + template: '', + }, +} + +export const appStubs: Record = { + ...baseStubs, + ...modalStubs, +} diff --git a/fe/src/test/utils/mount.ts b/fe/src/test/utils/mount.ts new file mode 100644 index 000000000..13a6d43f3 --- /dev/null +++ b/fe/src/test/utils/mount.ts @@ -0,0 +1,103 @@ +import { mount, type ComponentMountingOptions } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router' +import type { Component } from 'vue' + +type MountOptions = ComponentMountingOptions + +const defaultRoutes: RouteRecordRaw[] = [ + { path: '/', name: 'home', component: { template: '
' } }, +] + +type RouterOptions = { + initialPath?: string + routes?: RouteRecordRaw[] +} + +export function createTestRouter({ + initialPath = '/', + routes = defaultRoutes, +}: RouterOptions = {}) { + const router = createRouter({ + history: createMemoryHistory(), + routes, + }) + + return { + router, + ready: async () => { + await router.push(initialPath) + await router.isReady().catch(() => {}) + return router + }, + } +} + +export function createTestPinia() { + const pinia = createPinia() + setActivePinia(pinia) + return pinia +} + +export function mountWithPinia(component: Component, options: MountOptions = {}) { + const pinia = createTestPinia() + + return mount(component, { + ...options, + global: { + ...options.global, + plugins: [...(options.global?.plugins ?? []), pinia], + }, + }) +} + +export async function mountWithRouter( + component: Component, + options: MountOptions = {}, + routerOptions: RouterOptions = {}, +) { + const { router, ready } = createTestRouter(routerOptions) + await ready() + + return mount(component, { + ...options, + global: { + ...options.global, + plugins: [...(options.global?.plugins ?? []), router], + }, + }) +} + +export async function mountWithPiniaAndRouter( + component: Component, + options: MountOptions = {}, + routerOptions: RouterOptions = {}, +) { + const { router, ready } = createTestRouter(routerOptions) + const pinia = createTestPinia() + await ready() + + return mount(component, { + ...options, + global: { + ...options.global, + plugins: [...(options.global?.plugins ?? []), pinia, router], + }, + }) +} + +export function withStubs( + options: MountOptions, + stubs: NonNullable['stubs'], +) { + return { + ...options, + global: { + ...options.global, + stubs: { + ...(options.global?.stubs ?? {}), + ...stubs, + }, + }, + } satisfies MountOptions +} diff --git a/fe/src/test/utils/storage.ts b/fe/src/test/utils/storage.ts new file mode 100644 index 000000000..8ea06e6e2 --- /dev/null +++ b/fe/src/test/utils/storage.ts @@ -0,0 +1,62 @@ +import { vi } from 'vitest' + +export function installStorageMock() { + let localStore: Record = {} + let sessionStore: Record = {} + + const createStorage = ( + getStore: () => Record, + setStore: (store: Record) => void, + ) => ({ + getItem: vi.fn((key: string) => getStore()[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + getStore()[key] = `${value}` + }), + removeItem: vi.fn((key: string) => { + delete getStore()[key] + }), + clear: vi.fn(() => { + setStore({}) + }), + key: vi.fn((index: number) => Object.keys(getStore())[index] ?? null), + get length() { + return Object.keys(getStore()).length + }, + }) + + const localStorageMock = createStorage( + () => localStore, + (store) => { + localStore = store + }, + ) + const sessionStorageMock = createStorage( + () => sessionStore, + (store) => { + sessionStore = store + }, + ) + + vi.stubGlobal('localStorage', localStorageMock) + vi.stubGlobal('sessionStorage', sessionStorageMock) + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + configurable: true, + }) + Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, + configurable: true, + }) + + return { + localStorage: localStorageMock, + sessionStorage: sessionStorageMock, + get localStore() { + return localStore + }, + get sessionStore() { + return sessionStore + }, + } +} diff --git a/fe/src/test/utils/wait.ts b/fe/src/test/utils/wait.ts new file mode 100644 index 000000000..14023bfbe --- /dev/null +++ b/fe/src/test/utils/wait.ts @@ -0,0 +1,52 @@ +import { flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' + +type WaitForOptions = { + interval?: number + timeout?: number +} + +export const delay = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, resolve, reject } +} + +export async function flushAsync(ticks = 1) { + await flushPromises() + for (let i = 0; i < ticks; i++) { + await nextTick() + } + await delay(0) +} + +export async function waitFor( + assertion: () => void | boolean | Promise, + options: WaitForOptions = {}, +) { + const timeout = options.timeout ?? 1000 + const interval = options.interval ?? 20 + const start = Date.now() + let lastError: unknown + + while (Date.now() - start < timeout) { + try { + const result = await assertion() + if (result !== false) return + } catch (error) { + lastError = error + } + + await delay(interval) + } + + if (lastError instanceof Error) throw lastError + throw new Error(`Timed out after ${timeout}ms waiting for condition.`) +} diff --git a/fe/src/__tests__/audiobookStatus.spec.ts b/fe/src/utils/test/audiobookStatus.node.spec.ts similarity index 100% rename from fe/src/__tests__/audiobookStatus.spec.ts rename to fe/src/utils/test/audiobookStatus.node.spec.ts diff --git a/fe/src/__tests__/customFilterEvaluator.spec.ts b/fe/src/utils/test/customFilterEvaluator.node.spec.ts similarity index 99% rename from fe/src/__tests__/customFilterEvaluator.spec.ts rename to fe/src/utils/test/customFilterEvaluator.node.spec.ts index 5069595e5..177a69300 100644 --- a/fe/src/__tests__/customFilterEvaluator.spec.ts +++ b/fe/src/utils/test/customFilterEvaluator.node.spec.ts @@ -33,7 +33,7 @@ describe('customFilterEvaluator - grouping and precedence', () => { files: [], filePath: '', fileSize: 0, - } as unknown as Audiobook + } as any as Audiobook it('evaluates simple AND/OR grouping: (A OR B) AND C', () => { const rules = [ diff --git a/fe/src/__tests__/languageMapping.spec.ts b/fe/src/utils/test/languageMapping.node.spec.ts similarity index 100% rename from fe/src/__tests__/languageMapping.spec.ts rename to fe/src/utils/test/languageMapping.node.spec.ts diff --git a/fe/src/__tests__/libraryImportSearch.spec.ts b/fe/src/utils/test/libraryImportSearch.node.spec.ts similarity index 100% rename from fe/src/__tests__/libraryImportSearch.spec.ts rename to fe/src/utils/test/libraryImportSearch.node.spec.ts diff --git a/fe/src/__tests__/libraryImportTable.spec.ts b/fe/src/utils/test/libraryImportTable.node.spec.ts similarity index 100% rename from fe/src/__tests__/libraryImportTable.spec.ts rename to fe/src/utils/test/libraryImportTable.node.spec.ts diff --git a/fe/src/__tests__/utils/path.spec.ts b/fe/src/utils/test/path.node.spec.ts similarity index 100% rename from fe/src/__tests__/utils/path.spec.ts rename to fe/src/utils/test/path.node.spec.ts diff --git a/fe/src/__tests__/searchResultFormatting.spec.ts b/fe/src/utils/test/searchResultFormatting.node.spec.ts similarity index 97% rename from fe/src/__tests__/searchResultFormatting.spec.ts rename to fe/src/utils/test/searchResultFormatting.node.spec.ts index 3d7446348..b36e59f36 100644 --- a/fe/src/__tests__/searchResultFormatting.spec.ts +++ b/fe/src/utils/test/searchResultFormatting.node.spec.ts @@ -111,7 +111,7 @@ describe('searchResultFormatting', () => { it('returns empty string for falsy input', () => { expect(capitalizeLanguage('')).toBe('') expect(capitalizeLanguage(undefined)).toBe('') - expect(capitalizeLanguage(null as unknown)).toBe('') + expect(capitalizeLanguage(null as any)).toBe('') }) }) @@ -154,7 +154,7 @@ describe('searchResultFormatting', () => { it('returns undefined for empty/falsy input', () => { expect(getYearFromDate('')).toBeUndefined() expect(getYearFromDate(undefined)).toBeUndefined() - expect(getYearFromDate(null as unknown)).toBeUndefined() + expect(getYearFromDate(null as any)).toBeUndefined() }) it('handles edge cases', () => { diff --git a/fe/src/__tests__/searchResultHelpers.spec.ts b/fe/src/utils/test/searchResultHelpers.node.spec.ts similarity index 99% rename from fe/src/__tests__/searchResultHelpers.spec.ts rename to fe/src/utils/test/searchResultHelpers.node.spec.ts index 68bd17cb8..945283c8d 100644 --- a/fe/src/__tests__/searchResultHelpers.spec.ts +++ b/fe/src/utils/test/searchResultHelpers.node.spec.ts @@ -250,7 +250,7 @@ describe('searchResultHelpers', () => { }) it('detects audible by isEnriched flag', () => { - expect(isAudibleSource({ isEnriched: true } as unknown as NormalizedResult)).toBe(true) + expect(isAudibleSource({ isEnriched: true } as any as NormalizedResult)).toBe(true) }) it('returns false for non-audible sources', () => { diff --git a/fe/src/__tests__/sessionTokenStorage.test.ts b/fe/src/utils/test/sessionTokenStorage.spec.ts similarity index 89% rename from fe/src/__tests__/sessionTokenStorage.test.ts rename to fe/src/utils/test/sessionTokenStorage.spec.ts index 03d93af75..84885dc50 100644 --- a/fe/src/__tests__/sessionTokenStorage.test.ts +++ b/fe/src/utils/test/sessionTokenStorage.spec.ts @@ -15,11 +15,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { sessionTokenManager } from '@/utils/sessionToken' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { installStorageMock } from '@/test/utils/storage' describe('sessionTokenManager storage propagation', () => { beforeEach(() => { + vi.resetModules() + installStorageMock() // Ensure clean localStorage before each test try { localStorage.removeItem('listenarr_session_token') @@ -32,7 +34,8 @@ describe('sessionTokenManager storage propagation', () => { } catch {} }) - it('notifies subscribers when storage is changed (cross-tab)', () => { + it('notifies subscribers when storage is changed (cross-tab)', async () => { + const { sessionTokenManager } = await import('@/utils/sessionToken') const events: Array = [] const unsub = sessionTokenManager.onTokenChange((token) => { events.push(token) diff --git a/fe/src/__tests__/textUtils.spec.ts b/fe/src/utils/test/textUtils.spec.ts similarity index 100% rename from fe/src/__tests__/textUtils.spec.ts rename to fe/src/utils/test/textUtils.spec.ts diff --git a/fe/src/__tests__/ActivityView.mobile.spec.ts b/fe/src/views/activity/test/ActivityView.mobile.spec.ts similarity index 95% rename from fe/src/__tests__/ActivityView.mobile.spec.ts rename to fe/src/views/activity/test/ActivityView.mobile.spec.ts index eccced07c..3d38d240c 100644 --- a/fe/src/__tests__/ActivityView.mobile.spec.ts +++ b/fe/src/views/activity/test/ActivityView.mobile.spec.ts @@ -17,6 +17,7 @@ */ import { describe, it, beforeEach, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { delay } from '@/test/utils/wait' describe('ActivityView mobile virtualization', () => { beforeEach(() => { @@ -50,9 +51,7 @@ describe('ActivityView mobile virtualization', () => { canRemove: true, })) - vi.spyOn(globalThis, 'setInterval').mockReturnValue( - 1 as unknown as ReturnType, - ) + vi.spyOn(globalThis, 'setInterval').mockReturnValue(1 as any as ReturnType) vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => undefined) vi.doMock('@/services/signalr', () => ({ @@ -107,7 +106,7 @@ describe('ActivityView mobile virtualization', () => { }, }) - await new Promise((resolve) => setTimeout(resolve, 10)) + await delay(10) expect(wrapper.find('.queue-grid-container').classes()).toContain('is-static') expect(wrapper.find('.queue-body.is-static').exists()).toBe(true) diff --git a/fe/src/__tests__/ActivityView.spec.ts b/fe/src/views/activity/test/ActivityView.spec.ts similarity index 95% rename from fe/src/__tests__/ActivityView.spec.ts rename to fe/src/views/activity/test/ActivityView.spec.ts index d47810d26..3d64327e0 100644 --- a/fe/src/__tests__/ActivityView.spec.ts +++ b/fe/src/views/activity/test/ActivityView.spec.ts @@ -17,6 +17,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' +import { flushAsync } from '@/test/utils/wait' type ActivityItem = { id: string @@ -108,7 +109,7 @@ const mountActivityView = async () => { }) await flushPromises() - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() return wrapper } @@ -116,9 +117,7 @@ describe('ActivityView', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - vi.spyOn(globalThis, 'setInterval').mockReturnValue( - 1 as unknown as ReturnType, - ) + vi.spyOn(globalThis, 'setInterval').mockReturnValue(1 as any as ReturnType) vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => undefined) }) @@ -177,7 +176,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm expect(vm.allActivityItems.map((item) => item.id)).toEqual( expect.arrayContaining(['d1', 'd2', 'd3', 'd4']), @@ -216,7 +215,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm vm.filterText = 'two' await flushPromises() @@ -247,7 +246,7 @@ describe('ActivityView', () => { mockDownloadsStore() const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm const item = vm.allActivityItems.find((entry) => entry.id === 'q1') expect(item).toBeDefined() @@ -283,7 +282,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm const item = vm.allActivityItems.find((entry) => entry.id === 'ext-1') expect(item).toBeDefined() @@ -329,7 +328,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm expect(vm.allActivityItems).toHaveLength(2) expect(vm.allActivityItems.filter((item) => item.id === 'q1')).toHaveLength(1) @@ -348,7 +347,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm const item = vm.allActivityItems.find((entry) => entry.id === 'd1') expect(item).toBeDefined() @@ -394,7 +393,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm expect(vm.allActivityItems.find((item) => item.id === 'd-importpending')?.status).toBe( 'importpending', @@ -431,7 +430,7 @@ describe('ActivityView', () => { mockDownloadsStore() const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm expect(vm.queueHealthClients).toHaveLength(1) expect(vm.queueHealthClients[0]?.name).toBe('qBittorrent') @@ -485,7 +484,7 @@ describe('ActivityView', () => { }) const wrapper = await mountActivityView() - const vm = wrapper.vm as unknown as ActivityViewVm + const vm = wrapper.vm as any as ActivityViewVm expect(vm.allActivityItems).toHaveLength(1) expect(vm.allActivityItems[0]?.id).toBe('tracked-artemis') diff --git a/fe/src/__tests__/DownloadsView.spec.ts b/fe/src/views/activity/test/DownloadsView.spec.ts similarity index 98% rename from fe/src/__tests__/DownloadsView.spec.ts rename to fe/src/views/activity/test/DownloadsView.spec.ts index df4d3290c..04ab3abaa 100644 --- a/fe/src/__tests__/DownloadsView.spec.ts +++ b/fe/src/views/activity/test/DownloadsView.spec.ts @@ -17,6 +17,7 @@ */ import { describe, it, beforeEach, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { delay } from '@/test/utils/wait' describe('DownloadsView mobile virtualization', () => { beforeEach(() => { @@ -102,7 +103,7 @@ describe('DownloadsView mobile virtualization', () => { }, }) - await new Promise((resolve) => setTimeout(resolve, 10)) + await delay(10) expect(wrapper.find('.downloads-list-container').classes()).toContain('is-static') expect(wrapper.find('.downloads-list.is-static').exists()).toBe(true) diff --git a/fe/src/__tests__/AddNewView.spec.ts b/fe/src/views/content/test/AddNewView.spec.ts similarity index 85% rename from fe/src/__tests__/AddNewView.spec.ts rename to fe/src/views/content/test/AddNewView.spec.ts index 16729c9b7..8262792ae 100644 --- a/fe/src/__tests__/AddNewView.spec.ts +++ b/fe/src/views/content/test/AddNewView.spec.ts @@ -23,8 +23,47 @@ import { createPinia, setActivePinia } from 'pinia' import { createRouter, createMemoryHistory } from 'vue-router' import AddNewView from '@/views/content/AddNewView.vue' import { useLibraryStore } from '@/stores/library' - -// apiService and signalR are mocked centrally in test-setup.ts +import { installStorageMock } from '@/test/utils/storage' +import { delay } from '@/test/utils/wait' + +vi.mock('@/services/signalr', () => ({ + signalRService: { + connect: vi.fn(async () => undefined), + onDownloadsList: vi.fn(() => () => undefined), + onSearchProgress: vi.fn(() => () => undefined), + onQueueUpdate: vi.fn(() => () => undefined), + onDownloadUpdate: vi.fn(() => () => undefined), + onFilesRemoved: vi.fn(() => () => undefined), + onAudiobookUpdate: vi.fn(() => () => undefined), + onNotification: vi.fn(() => () => undefined), + onToast: vi.fn(() => () => undefined), + }, +})) + +vi.mock('@/services/api', () => { + const apiService = { + searchAudibleByTitleAndAuthor: vi.fn(async () => ({ totalResults: 0, results: [] })), + advancedSearch: vi.fn(async (params: unknown) => { + const p = params as { title?: string; author?: string } | undefined + if (p?.title) { + const resp = await (apiService.searchAudibleByTitleAndAuthor as Mock)(p.title, p.author) + return resp.results || resp || [] + } + return { totalResults: 0, results: [] } + }), + getImageUrl: vi.fn((url: string) => url || ''), + getStartupConfig: vi.fn(async () => ({})), + getApplicationSettings: vi.fn(async () => ({})), + getLibrary: vi.fn(async () => []), + } + + return { + apiService, + getStartupConfig: apiService.getStartupConfig, + getApplicationSettings: apiService.getApplicationSettings, + ensureImageCached: vi.fn(async (url: string) => url || ''), + } +}) describe('AddNewView pagination', () => { const createTestRouter = () => @@ -35,6 +74,7 @@ describe('AddNewView pagination', () => { beforeEach(() => { vi.clearAllMocks() + installStorageMock() window.localStorage.clear() const pinia = createPinia() setActivePinia(pinia) @@ -54,7 +94,7 @@ describe('AddNewView pagination', () => { it('does not render empty results controls when title results have no pagination controls', async () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchType?: string titleResults?: unknown[] } @@ -80,7 +120,7 @@ describe('AddNewView pagination', () => { it('renders results controls when title results need client-side pagination', async () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchType?: string titleResults?: unknown[] totalTitleResultsCount?: number @@ -107,7 +147,7 @@ describe('AddNewView pagination', () => { it('maps audible metadata to result fields', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -130,7 +170,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { showAdvancedSearch?: boolean advancedSearchParams?: Record performAdvancedSearch?: () => Promise @@ -146,7 +186,7 @@ describe('AddNewView pagination', () => { expect(vm.allAudibleResults.length).toBe(1) expect(vm.titleResults.length).toBe(1) - const tr = vm.titleResults[0] as unknown + const tr = vm.titleResults[0] as any expect(tr.searchResult.narrator).toBe('Scott Brick') expect(tr.searchResult.subtitle).toBe('A Heroic Saga') expect(tr.searchResult.series).toBe('Dune Series') @@ -163,7 +203,7 @@ describe('AddNewView pagination', () => { it('sets data-src for lazy images on advanced search results', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -180,7 +220,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { showAdvancedSearch?: boolean advancedSearchParams?: Record performAdvancedSearch?: () => Promise @@ -226,7 +266,7 @@ describe('AddNewView pagination', () => { it('applies configured default region and language from application settings', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { getApplicationSettings?: Mock } + const apiService = apiModule.apiService as any as { getApplicationSettings?: Mock } apiService.getApplicationSettings?.mockResolvedValue({ defaultSearchRegion: 'de', defaultSearchLanguage: 'polish', @@ -236,7 +276,7 @@ describe('AddNewView pagination', () => { const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) await flushPromises() - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchLanguage?: string preferredSearchLanguage?: string advancedSearchParams?: { language?: string } @@ -248,7 +288,7 @@ describe('AddNewView pagination', () => { it('omits language filtering when default language is set to all', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { getApplicationSettings?: Mock } + const apiService = apiModule.apiService as any as { getApplicationSettings?: Mock } apiService.getApplicationSettings?.mockResolvedValue({ defaultSearchRegion: 'de', defaultSearchLanguage: 'all', @@ -259,7 +299,7 @@ describe('AddNewView pagination', () => { const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) await flushPromises() - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchQuery?: string preferredSearchLanguage?: string performSearch?: () => Promise @@ -280,7 +320,7 @@ describe('AddNewView pagination', () => { it('filters mixed-language audible results using the selected language while keeping the default region', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { getApplicationSettings?: Mock } + const apiService = apiModule.apiService as any as { getApplicationSettings?: Mock } apiService.getApplicationSettings?.mockResolvedValue({ defaultSearchRegion: 'de', defaultSearchLanguage: 'english', @@ -300,13 +340,13 @@ describe('AddNewView pagination', () => { imageUrl: 'http://img-de', language: 'de', }, - ]) + ] as any) const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) await flushPromises() - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchQuery?: string titleResults?: Array<{ title?: string }> performSearch?: () => Promise @@ -328,7 +368,7 @@ describe('AddNewView pagination', () => { it('defaults to title search for simple unprefixed queries (simple search)', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -344,7 +384,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchQuery?: string performSearch?: () => Promise titleResults?: unknown[] @@ -363,13 +403,13 @@ describe('AddNewView pagination', () => { expect(hint.text()).toContain('Searching by title') expect(vm.titleResults.length).toBe(1) - const tr = vm.titleResults[0] as unknown + const tr = vm.titleResults[0] as any expect(tr.title).toBe('Dune Simple') }) it('defaults to title search for simple unprefixed queries (advanced path)', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -385,7 +425,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchQuery?: string performAdvancedSearch?: () => Promise titleResults?: unknown[] @@ -398,18 +438,18 @@ describe('AddNewView pagination', () => { await wrapper.vm.$nextTick() expect(vm.titleResults.length).toBe(1) - const tr = vm.titleResults[0] as unknown + const tr = vm.titleResults[0] as any expect(tr.title).toBe('Dune Simple') }) it('shows toast and scrolls to input when simple search returns no results', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 0, results: [] }) const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchQuery?: string performSearch?: () => Promise } @@ -421,7 +461,7 @@ describe('AddNewView pagination', () => { await vm.performSearch() await wrapper.vm.$nextTick() // allow microtasks to flush so the watch handler runs and any scroll is triggered - await new Promise((r) => setTimeout(r, 10)) + await delay(10) const toastSvc = (await import('@/services/toastService')).useToast() expect(toastSvc.toasts.length).toBeGreaterThan(0) @@ -434,7 +474,7 @@ describe('AddNewView pagination', () => { it('maps runtime from runtimeLengthMin (minutes) and keeps as minutes', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -451,7 +491,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { showAdvancedSearch?: boolean advancedSearchParams?: Record performAdvancedSearch?: () => Promise @@ -464,13 +504,13 @@ describe('AddNewView pagination', () => { await vm.performAdvancedSearch() expect(vm.titleResults.length).toBe(1) - const tr = vm.titleResults[0] as unknown + const tr = vm.titleResults[0] as any expect(tr.searchResult.runtime).toBe(10) }) it('maps runtime from lengthMinutes (metadata field) and keeps as minutes', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -487,7 +527,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { showAdvancedSearch?: boolean advancedSearchParams?: Record performAdvancedSearch?: () => Promise @@ -499,13 +539,13 @@ describe('AddNewView pagination', () => { await vm.performAdvancedSearch() expect(vm.titleResults.length).toBe(1) - const tr = vm.titleResults[0] as unknown + const tr = vm.titleResults[0] as any expect(tr.searchResult.runtime).toBe(12) }) it('renders formatted runtime string for advanced search results', async () => { const apiModule = await import('@/services/api') - const apiService = apiModule.apiService as unknown as { searchAudibleByTitleAndAuthor?: Mock } + const apiService = apiModule.apiService as any as { searchAudibleByTitleAndAuthor?: Mock } apiService.searchAudibleByTitleAndAuthor?.mockResolvedValue({ totalResults: 1, results: [ @@ -522,7 +562,7 @@ describe('AddNewView pagination', () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { showAdvancedSearch?: boolean advancedSearchParams?: Record performAdvancedSearch?: () => Promise @@ -545,14 +585,14 @@ describe('AddNewView pagination', () => { it('shows metadata badge linking to the Audible product page and source badge linking to Audible product', async () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchType?: string audibleResult?: Record } // Simulate an ASIN-based Audible-backed result (single result view) vm.searchType = 'asin' - ;(vm as unknown).audibleResult = { + ;(vm as any).audibleResult = { asin: 'BAUD1', title: 'Title', authors: [{ name: 'Author Name' }], @@ -583,13 +623,13 @@ describe('AddNewView pagination', () => { it('does not label non-Audible URLs containing audible.com as Audible', async () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchType?: string audibleResult?: Record } vm.searchType = 'asin' - ;(vm as unknown).audibleResult = { + ;(vm as any).audibleResult = { asin: 'BAUDX', title: 'Title', source: 'External', @@ -604,7 +644,7 @@ describe('AddNewView pagination', () => { } // Also ensure fake hostnames are not treated as Audible - ;(vm as unknown).audibleResult.sourceLink = 'https://fakeaudible.com/pd/123' + ;(vm as any).audibleResult.sourceLink = 'https://fakeaudible.com/pd/123' await wrapper.vm.$nextTick() sourceLink = wrapper.find('.result-meta .source-link') if (sourceLink.text().includes('Audible')) { @@ -615,7 +655,7 @@ describe('AddNewView pagination', () => { it('shows full series list on hover (title and asin result views)', async () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { searchType?: string; titleResults?: unknown[] } + const vm = wrapper.vm as any as { searchType?: string; titleResults?: unknown[] } // Title-list item case vm.searchType = 'title' @@ -635,7 +675,7 @@ describe('AddNewView pagination', () => { // ASIN result case vm.searchType = 'asin' - ;(vm as unknown).audibleResult = { + ;(vm as any).audibleResult = { asin: 'BAUD2', title: 'B', series: 'X', @@ -650,7 +690,7 @@ describe('AddNewView pagination', () => { it('shows "Added" and disables add button when result is already in library', async () => { const router = createTestRouter() const wrapper = mount(AddNewView, { global: { plugins: [createPinia(), router] } }) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { searchType?: string audibleResult?: Record checkExistingInLibrary?: () => Promise diff --git a/fe/src/__tests__/WantedView.spec.ts b/fe/src/views/content/test/WantedView.spec.ts similarity index 93% rename from fe/src/__tests__/WantedView.spec.ts rename to fe/src/views/content/test/WantedView.spec.ts index e25f66ec9..9382b2de0 100644 --- a/fe/src/__tests__/WantedView.spec.ts +++ b/fe/src/views/content/test/WantedView.spec.ts @@ -22,6 +22,7 @@ import WantedView from '@/views/content/WantedView.vue' import { useLibraryStore } from '@/stores/library' import { useDownloadsStore } from '@/stores/downloads' import { API_BASE_PATH } from '@/services/apiBase' +import { delay } from '@/test/utils/wait' // Mock api service ensureImageCached and getImageUrl (and other helpers used by stores) vi.mock('@/services/api', () => ({ @@ -63,7 +64,7 @@ describe('WantedView image recache behavior', () => { store.audiobooks = [ { id: 1, title: 'Book 1', monitored: true, files: [], imageUrl: `${imageBasePath}/ASIN1` }, { id: 2, title: 'Book 2', monitored: true, files: [], imageUrl: `${imageBasePath}/ASIN2` }, - ] as unknown as ReturnType['audiobooks'] + ] as any as ReturnType['audiobooks'] // Prevent fetchLibrary from running during mount store.fetchLibrary = vi.fn(async () => undefined) @@ -71,7 +72,7 @@ describe('WantedView image recache behavior', () => { const wrapper = mount(WantedView, { global: { plugins: [pinia] } }) // Allow onMounted work to complete - await new Promise((r) => setTimeout(r, 10)) + await delay(10) // Ensure the image element was rendered with the expected src (avoid relying on internal mock call) const img = wrapper.find('img') @@ -88,7 +89,7 @@ describe('WantedView image recache behavior', () => { libraryStore.audiobooks = [ { id: 101, title: 'Pending Book', monitored: true, files: [] }, { id: 202, title: 'Blocked Book', monitored: true, files: [] }, - ] as unknown as ReturnType['audiobooks'] + ] as any as ReturnType['audiobooks'] libraryStore.fetchLibrary = vi.fn(async () => undefined) const downloadsStore = useDownloadsStore() @@ -118,9 +119,9 @@ describe('WantedView image recache behavior', () => { ] as ReturnType['downloads'] const wrapper = mount(WantedView, { global: { plugins: [pinia] } }) - await new Promise((r) => setTimeout(r, 10)) + await delay(10) - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { hasActiveDownload: (audiobook: { id: number }) => boolean getStatusText: (audiobook: { id: number }) => string } @@ -156,11 +157,11 @@ describe('WantedView image recache behavior', () => { title: `Wanted Book ${index + 1}`, monitored: true, files: [], - })) as unknown as ReturnType['audiobooks'] + })) as any as ReturnType['audiobooks'] libraryStore.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(WantedView, { global: { plugins: [pinia] } }) - await new Promise((resolve) => setTimeout(resolve, 10)) + await delay(10) expect(wrapper.find('.wanted-grid-container').classes()).toContain('is-static') expect(wrapper.find('.wanted-body.is-static').exists()).toBe(true) diff --git a/fe/src/__tests__/AudiobookDetailView.spec.ts b/fe/src/views/library/test/AudiobookDetailView.spec.ts similarity index 92% rename from fe/src/__tests__/AudiobookDetailView.spec.ts rename to fe/src/views/library/test/AudiobookDetailView.spec.ts index 6f3830f04..ed67fe193 100644 --- a/fe/src/__tests__/AudiobookDetailView.spec.ts +++ b/fe/src/views/library/test/AudiobookDetailView.spec.ts @@ -21,6 +21,7 @@ import { describe, it, beforeEach, expect, vi } from 'vitest' import { API_BASE_PATH } from '@/services/apiBase' import { useLibraryStore } from '@/stores/library' import { ensureImageCached } from '@/services/api' +import { delay, flushAsync } from '@/test/utils/wait' import AudiobookDetailViewCmp from '@/views/library/AudiobookDetailView.vue' const routerPushMock = vi.fn() // Mock useRoute to provide params for the detail view @@ -67,15 +68,15 @@ describe('AudiobookDetailView image recache behavior', () => { const store = useLibraryStore() store.audiobooks = [ { id: 5, title: 'Detail Book', imageUrl: imagePath, files: [] }, - ] as unknown as ReturnType['audiobooks'] + ] as any as ReturnType['audiobooks'] store.fetchLibrary = vi.fn(async () => undefined) mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) - await new Promise((r) => setTimeout(r, 10)) + await delay(10) expect(ensureImageCached).toHaveBeenCalled() - const ensureImageCachedMock = ensureImageCached as unknown as { + const ensureImageCachedMock = ensureImageCached as any as { mock: { calls: Array<[string]> } } expect(ensureImageCachedMock.mock.calls[0]?.[0]).toBe(imagePath) @@ -96,12 +97,12 @@ describe('AudiobookDetailView image recache behavior', () => { genres: ['Fantasy'], files: [], }, - ] as unknown as ReturnType['audiobooks'] + ] as any as ReturnType['audiobooks'] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) - await new Promise((r) => setTimeout(r, 10)) + await delay(10) const authorTag = wrapper .findAll('.detail-link-tag') @@ -157,7 +158,7 @@ describe('AudiobookDetailView image recache behavior', () => { authors: ['Author One'], files: [], }, - ] as unknown as ReturnType['audiobooks'] + ] as any as ReturnType['audiobooks'] store.fetchLibrary = vi.fn(async () => undefined) @@ -173,13 +174,13 @@ describe('AudiobookDetailView image recache behavior', () => { }, }, }) - await new Promise((r) => setTimeout(r, 10)) + await delay(10) const editButton = wrapper.find('button[aria-label="Edit Metadata"]') expect(editButton.exists()).toBe(true) await editButton.trigger('click') - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() expect(wrapper.find('.edit-audiobook-modal-stub').attributes('data-open')).toBe('true') }) diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/views/library/test/AudiobooksView.spec.ts similarity index 78% rename from fe/src/__tests__/AudiobooksView.spec.ts rename to fe/src/views/library/test/AudiobooksView.spec.ts index 29a6e501d..445194e08 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/views/library/test/AudiobooksView.spec.ts @@ -15,12 +15,15 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { createRouter, createMemoryHistory } from 'vue-router' import AudiobooksView from '@/views/library/AudiobooksView.vue' import { useLibraryStore } from '@/stores/library' +import { useDownloadsStore } from '@/stores/downloads' +import { installStorageMock } from '@/test/utils/storage' +import { flushAsync } from '@/test/utils/wait' // apiService stubbed in vi.mock below if needed vi.mock('@/services/api', () => ({ @@ -38,27 +41,72 @@ type AudiobooksVm = { showItemDetails?: boolean } -const getVm = (wrapper: ReturnType) => wrapper.vm as unknown as AudiobooksVm +const getVm = (wrapper: ReturnType) => wrapper.vm as any as AudiobooksVm +const mountedWrappers: Array> = [] + +function mountAudiobooksView(options: Parameters[1]) { + const wrapper = mount(AudiobooksView, options) + mountedWrappers.push(wrapper) + return wrapper +} + +function installBrowserMocks() { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ) + + vi.stubGlobal( + 'WebSocket', + class { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readyState = 3 + + send() {} + close() {} + }, + ) +} + +beforeEach(() => { + installBrowserMocks() + installStorageMock() +}) + +afterEach(() => { + for (const wrapper of mountedWrappers.splice(0)) { + wrapper.unmount() + } + + useDownloadsStore().cleanup() + vi.unstubAllGlobals() +}) describe('AudiobooksView', () => { beforeEach(() => { + installStorageMock() const pinia = createPinia() setActivePinia(pinia) }) it('shows extra details in grid view when showItemDetails is enabled', async () => { // ensure ResizeObserver is defined for the mount in vtu - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } // Minimal WebSocket stub so SignalRService doesn't throw during tests - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -86,13 +134,13 @@ describe('AudiobooksView', () => { imageUrl: 'https://example.com/cover.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] // Persist 'showItemDetails' so component mounts with details on localStorage.setItem('listenarr.showItemDetails', 'true') // Prevent real fetchLibrary from running during mount (we set audiobooks directly) store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -104,7 +152,7 @@ describe('AudiobooksView', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Find the rendered extra details block under the poster in the grid const bottomDetails = wrapper.find('.grid-bottom-details') @@ -124,16 +172,14 @@ describe('AudiobooksView Grouping', () => { }) it('groups audiobooks by author when groupBy is authors', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -176,10 +222,10 @@ describe('AudiobooksView Grouping', () => { imageUrl: 'cover3.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -191,7 +237,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Set groupBy to authors const vm = getVm(wrapper) @@ -212,21 +258,19 @@ describe('AudiobooksView Grouping', () => { }) // Default sorting when grouped by authors should be author-last ascending - expect((vm as unknown).sortKey).toBe('author-last') - expect((vm as unknown).sortOrder).toBe('asc') + expect((vm as any).sortKey).toBe('author-last') + expect((vm as any).sortOrder).toBe('asc') }) it('groups audiobooks by series when groupBy is series', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -269,10 +313,10 @@ describe('AudiobooksView Grouping', () => { imageUrl: 'cover3.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -284,7 +328,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Set groupBy to series const vm = getVm(wrapper) @@ -323,10 +367,10 @@ describe('AudiobooksView Grouping', () => { { id: 1, title: 'A1', authors: ['Author A'], series: 'Series X', imageUrl: 'c1', files: [] }, { id: 2, title: 'A2', authors: ['Author A'], series: 'Series X', imageUrl: 'c2', files: [] }, { id: 3, title: 'B1', authors: ['Author B'], series: 'Series Y', imageUrl: 'c3', files: [] }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -338,22 +382,22 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() - const vm = wrapper.vm as unknown as unknown + const vm = wrapper.vm as any as any // Switch to authors grouping and verify sortOptions exposed for collections await vm.setGroupBy('authors') await wrapper.vm.$nextTick() - const optValues = (vm.sortOptions || []).map((o: unknown) => o.value) + const optValues = (vm.sortOptions || []).map((o: any) => o.value) expect(optValues).toContain('author-last') expect(optValues).toContain('author-first') expect(optValues).toContain('count') // Default sorting when grouped by authors should be author-last ascending - expect((vm as unknown).sortKey).toBe('author-last') - expect((vm as unknown).sortOrder).toBe('asc') + expect((vm as any).sortKey).toBe('author-last') + expect((vm as any).sortOrder).toBe('asc') // CustomSelect should not be marked "active" for the default author sort const csStub = wrapper.find('custom-select-stub') @@ -377,14 +421,14 @@ describe('AudiobooksView Grouping', () => { // Switch to series grouping and verify options await vm.setGroupBy('series') await wrapper.vm.$nextTick() - const seriesOpt = (vm.sortOptions || []).map((o: unknown) => o.value) + const seriesOpt = (vm.sortOptions || []).map((o: any) => o.value) expect(seriesOpt).toContain('title') expect(seriesOpt).toContain('count') expect(seriesOpt).not.toContain('author-last') // Series default should be `title` ascending and the control should NOT be active - expect((vm as unknown).sortKey).toBe('title') - expect((vm as unknown).sortOrder).toBe('asc') + expect((vm as any).sortKey).toBe('title') + expect((vm as any).sortOrder).toBe('asc') expect(wrapper.find('custom-select-stub').attributes('active')).toBe('false') // Sort series by count ascending (non-default) @@ -396,16 +440,14 @@ describe('AudiobooksView Grouping', () => { }) it('shows individual books when groupBy is books', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -432,12 +474,12 @@ describe('AudiobooksView Grouping', () => { imageUrl: 'cover1.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) // Ensure groupBy is 'books' localStorage.setItem('listenarr.groupBy', 'books') - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -449,7 +491,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // groupBy defaults to 'books' const vm = getVm(wrapper) @@ -474,10 +516,10 @@ describe('AudiobooksView Grouping', () => { // single audiobook that would be shown when no filters/search applied store.audiobooks = [ { id: 1, title: 'Visible Book', authors: ['Author A'], imageUrl: 'c1', files: [] }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -490,7 +532,7 @@ describe('AudiobooksView Grouping', () => { }, }) - const vm = wrapper.vm as unknown as unknown + const vm = wrapper.vm as any as any // Apply a search that yields no results and a custom filter selection vm.searchQuery = 'no-match-query' @@ -518,16 +560,14 @@ describe('AudiobooksView Grouping', () => { }) it('route query group parameter overrides stored preference on initial load', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -557,10 +597,10 @@ describe('AudiobooksView Grouping', () => { imageUrl: 'cover1.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -572,23 +612,21 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Expect the component to use the route query 'books' despite stored 'series' - expect((wrapper.vm as unknown as { groupBy: string }).groupBy).toBe('books') + expect((wrapper.vm as any as { groupBy: string }).groupBy).toBe('books') }) it('clears selection when changing grouping mode', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -623,10 +661,10 @@ describe('AudiobooksView Grouping', () => { imageUrl: 'cover2.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -638,7 +676,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Select one item store.toggleSelection(1) @@ -652,16 +690,14 @@ describe('AudiobooksView Grouping', () => { }) it('series bottom placard is only visible when showItemDetails is enabled', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -699,10 +735,10 @@ describe('AudiobooksView Grouping', () => { imageUrl: 'cover2.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) - const wrapper = mount(AudiobooksView, { + const wrapper = mountAudiobooksView({ global: { plugins: [pinia, router], stubs: [ @@ -714,7 +750,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Set groupBy to series const vm = getVm(wrapper) diff --git a/fe/src/__tests__/CollectionView.spec.ts b/fe/src/views/library/test/CollectionView.spec.ts similarity index 96% rename from fe/src/__tests__/CollectionView.spec.ts rename to fe/src/views/library/test/CollectionView.spec.ts index 511be1d60..de8a958b2 100644 --- a/fe/src/__tests__/CollectionView.spec.ts +++ b/fe/src/views/library/test/CollectionView.spec.ts @@ -21,6 +21,7 @@ import { createPinia, setActivePinia } from 'pinia' import { createRouter, createMemoryHistory } from 'vue-router' import CollectionView from '@/views/library/CollectionView.vue' import { useLibraryStore } from '@/stores/library' +import { flushAsync } from '@/test/utils/wait' const { mockGetLibrary, @@ -156,7 +157,7 @@ describe('CollectionView', () => { addedCount: 0, existingCount: 0, failedCount: 0, - }) + } as any) mockMonitorSeries.mockReset() mockMonitorSeries.mockResolvedValue({ message: 'Series monitoring enabled', @@ -171,7 +172,7 @@ describe('CollectionView', () => { addedCount: 0, existingCount: 0, failedCount: 0, - }) + } as any) mockUnmonitorAuthor.mockReset() mockUnmonitorAuthor.mockResolvedValue({ message: 'Author monitoring disabled' }) mockUnmonitorSeries.mockReset() @@ -182,16 +183,14 @@ describe('CollectionView', () => { }) it('shows collection content details', async () => { - if ( - typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' - ) { - ;(globalThis as unknown as Record).ResizeObserver = class { + if (typeof (globalThis as any as { ResizeObserver?: unknown }).ResizeObserver === 'undefined') { + ;(globalThis as any as Record).ResizeObserver = class { observe() {} disconnect() {} } } - if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { - ;(globalThis as unknown as Record).WebSocket = function () { + if (typeof (globalThis as any as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as any as Record).WebSocket = function () { /* noop */ } } @@ -227,7 +226,7 @@ describe('CollectionView', () => { imageUrl: 'c2.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { @@ -236,7 +235,7 @@ describe('CollectionView', () => { stubs: ['EditAudiobookModal', 'CustomSelect', 'AddLibraryModal'], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // ensure grid view expect(wrapper.vm.viewMode).toBe('grid') @@ -291,7 +290,7 @@ describe('CollectionView', () => { imageUrl: 'fantasy-2.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { @@ -300,7 +299,7 @@ describe('CollectionView', () => { stubs: ['EditAudiobookModal', 'CustomSelect', 'AddLibraryModal'], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() const collectionCards = wrapper.findAll('.collection-card') expect(collectionCards).toHaveLength(2) @@ -349,7 +348,7 @@ describe('CollectionView', () => { imageUrl: 'children.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { @@ -358,7 +357,7 @@ describe('CollectionView', () => { stubs: ['EditAudiobookModal', 'CustomSelect', 'AddLibraryModal'], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() const collectionCards = wrapper.findAll('.collection-card') expect(collectionCards).toHaveLength(2) @@ -407,7 +406,7 @@ describe('CollectionView', () => { imageUrl: 'book-3.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { @@ -416,7 +415,7 @@ describe('CollectionView', () => { stubs: ['EditAudiobookModal', 'CustomSelect', 'AddLibraryModal'], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() const collectionCards = wrapper.findAll('.collection-card') expect(collectionCards).toHaveLength(2) @@ -443,7 +442,7 @@ describe('CollectionView', () => { const store = useLibraryStore() store.audiobooks = [ { id: 1, title: 'Book A', authors: ['Author A'], imageUrl: 'a.jpg', files: [] }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) @@ -454,7 +453,7 @@ describe('CollectionView', () => { }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() const toolbar = wrapper.find('.toolbar') expect(toolbar.exists()).toBe(true) @@ -532,7 +531,7 @@ describe('CollectionView', () => { imageUrl: 'local.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.audiobooks = localLibrary mockGetLibrary.mockResolvedValue(localLibrary) store.fetchLibrary = vi.fn(async () => undefined) @@ -638,7 +637,7 @@ describe('CollectionView', () => { imageUrl: 'book1.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.audiobooks = localLibrary mockGetLibrary.mockResolvedValue(localLibrary) store.fetchLibrary = vi.fn(async () => undefined) @@ -723,7 +722,7 @@ describe('CollectionView', () => { imageUrl: 'local.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.audiobooks = localLibrary mockGetLibrary.mockResolvedValue(localLibrary) store.fetchLibrary = vi.fn(async () => undefined) @@ -833,7 +832,7 @@ describe('CollectionView', () => { imageUrl: 'local.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.audiobooks = localLibrary mockGetLibrary.mockResolvedValue(localLibrary) store.fetchLibrary = vi.fn(async () => undefined) @@ -935,7 +934,7 @@ describe('CollectionView', () => { imageUrl: 'local.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.audiobooks = localLibrary mockGetLibrary.mockResolvedValue(localLibrary) store.fetchLibrary = vi.fn(async () => undefined) @@ -1014,7 +1013,7 @@ describe('CollectionView', () => { imageUrl: 'local.jpg', files: [], }, - ] as unknown as import('@/types').Audiobook[] + ] as any as import('@/types').Audiobook[] store.audiobooks = localLibrary mockGetLibrary.mockResolvedValue(localLibrary) store.fetchLibrary = vi.fn(async () => undefined) @@ -1072,7 +1071,7 @@ describe('CollectionView', () => { addedCount: 1, existingCount: 0, failedCount: 0, - }) + } as any) const router = createRouter({ history: createMemoryHistory(), @@ -1086,7 +1085,7 @@ describe('CollectionView', () => { await router.isReady().catch(() => {}) const store = useLibraryStore() - store.audiobooks = [] as unknown as import('@/types').Audiobook[] + store.audiobooks = [] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { @@ -1155,7 +1154,7 @@ describe('CollectionView', () => { addedCount: 1, existingCount: 0, failedCount: 0, - }) + } as any) const router = createRouter({ history: createMemoryHistory(), @@ -1169,7 +1168,7 @@ describe('CollectionView', () => { await router.isReady().catch(() => {}) const store = useLibraryStore() - store.audiobooks = [] as unknown as import('@/types').Audiobook[] + store.audiobooks = [] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { @@ -1255,7 +1254,7 @@ describe('CollectionView', () => { await router.isReady().catch(() => {}) const store = useLibraryStore() - store.audiobooks = [] as unknown as import('@/types').Audiobook[] + store.audiobooks = [] as any as import('@/types').Audiobook[] store.fetchLibrary = vi.fn(async () => undefined) const wrapper = mount(CollectionView, { diff --git a/fe/src/__tests__/audiobook-detailview.signalr.spec.ts b/fe/src/views/library/test/audiobook-detailview.signalr.spec.ts similarity index 93% rename from fe/src/__tests__/audiobook-detailview.signalr.spec.ts rename to fe/src/views/library/test/audiobook-detailview.signalr.spec.ts index 02786cbdf..17f3fe13e 100644 --- a/fe/src/__tests__/audiobook-detailview.signalr.spec.ts +++ b/fe/src/views/library/test/audiobook-detailview.signalr.spec.ts @@ -21,6 +21,7 @@ import { describe, it, beforeEach, expect, vi } from 'vitest' import { signalRService } from '@/services/signalr' import AudiobookDetailView from '@/views/library/AudiobookDetailView.vue' import { useLibraryStore } from '@/stores/library' +import { delay } from '@/test/utils/wait' import type { Audiobook } from '@/types' // Mock useRoute to provide params for the detail view @@ -49,7 +50,7 @@ describe('AudiobookDetailView SignalR integration', () => { }) // Ensure other signalR callbacks used by the component exist to avoid runtime errors - ;(signalRService as unknown).onScanJobUpdate = (cb?: (...args: unknown[]) => void) => { + ;(signalRService as any).onScanJobUpdate = (cb?: (...args: unknown[]) => void) => { void cb return () => {} } @@ -71,7 +72,7 @@ describe('AudiobookDetailView SignalR integration', () => { }) // Allow loadAudiobook to finish - await new Promise((r) => setTimeout(r, 10)) + await delay(10) await wrapper.vm.$nextTick() // Assert initial values present in DOM (details tab) @@ -97,7 +98,7 @@ describe('AudiobookDetailView SignalR integration', () => { expect(callbacks.length).toBeGreaterThan(0) // Ensure other signalR callbacks used by the component exist to avoid runtime errors - ;(signalRService as unknown).onScanJobUpdate = (cb?: (...args: unknown[]) => void) => { + ;(signalRService as any).onScanJobUpdate = (cb?: (...args: unknown[]) => void) => { void cb return () => {} } @@ -105,7 +106,7 @@ describe('AudiobookDetailView SignalR integration', () => { callbacks[0](serverDto as Audiobook) // Wait for merge and DOM update - await new Promise((r) => setTimeout(r, 10)) + await delay(10) await wrapper.vm.$nextTick() // Assert DOM updated diff --git a/fe/src/__tests__/DownloadClientsTab.spec.ts b/fe/src/views/settings/test/DownloadClientsTab.spec.ts similarity index 87% rename from fe/src/__tests__/DownloadClientsTab.spec.ts rename to fe/src/views/settings/test/DownloadClientsTab.spec.ts index 4c1ed9539..ab27df81a 100644 --- a/fe/src/__tests__/DownloadClientsTab.spec.ts +++ b/fe/src/views/settings/test/DownloadClientsTab.spec.ts @@ -20,11 +20,12 @@ import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import DownloadClientsTab from '@/views/settings/DownloadClientsTab.vue' import { useConfigurationStore } from '@/stores/configuration' +import { createDownloadClientConfiguration } from '@/test/factories/downloadClient' vi.mock('@/services/api', async (importOriginal) => { const actual = await importOriginal() return { - ...(actual as unknown), + ...(actual as any), testDownloadClient: vi.fn(async (config) => ({ success: true, message: 'ok', client: config })), getRemotePathMappings: vi.fn(async () => []), } @@ -37,19 +38,11 @@ describe('DownloadClientsTab', () => { const store = useConfigurationStore() // seed store with a client - const client: unknown = { + const client = createDownloadClientConfiguration({ id: 'db-1', name: 'qbt', - type: 'qbittorrent', host: 'dbhost.local', - port: 8080, - isEnabled: true, - useSSL: false, - downloadPath: '', - username: '', - password: '', - settings: {}, - } + }) store.downloadClientConfigurations = [client] const wrapper = mount(DownloadClientsTab, { @@ -62,7 +55,7 @@ describe('DownloadClientsTab', () => { const api = await import('@/services/api') expect(api.testDownloadClient).toHaveBeenCalled() - const calledWith = (api.testDownloadClient as unknown).mock.calls[0][0] + const calledWith = (api.testDownloadClient as any).mock.calls[0][0] expect(calledWith.host).toBe('dbhost.local') expect(calledWith.port).toBe(8080) }) @@ -74,7 +67,7 @@ describe('DownloadClientsTab', () => { // simulate loading state on the store store.isLoading = true - store.downloadClientConfigurations = [] as unknown + store.downloadClientConfigurations = [] as any const wrapper = mount(DownloadClientsTab, { global: { plugins: [pinia] } }) diff --git a/fe/src/__tests__/IndexersTab.spec.ts b/fe/src/views/settings/test/IndexersTab.spec.ts similarity index 82% rename from fe/src/__tests__/IndexersTab.spec.ts rename to fe/src/views/settings/test/IndexersTab.spec.ts index 3826b4254..9372de077 100644 --- a/fe/src/__tests__/IndexersTab.spec.ts +++ b/fe/src/views/settings/test/IndexersTab.spec.ts @@ -18,6 +18,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' +import { modalStubs } from '@/test/stubs' +import { flushAsync } from '@/test/utils/wait' // We'll mock getIndexers so we can control its resolution during the test describe('IndexersTab', () => { @@ -35,7 +37,7 @@ describe('IndexersTab', () => { vi.doMock('@/services/api', async (importOriginal) => { const actual = await importOriginal() return { - ...(actual as unknown), + ...(actual as any), getIndexers: vi.fn(() => new Promise((res) => (resolveFn = res))), getProwlarrImportSettings: vi.fn().mockResolvedValue({ url: '', @@ -51,12 +53,12 @@ describe('IndexersTab', () => { const sr = await import('@/services/signalr') // provide a no-op subscription function if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { - ;(sr as unknown).signalRService = { onIndexersUpdated: () => () => {} } as unknown + ;(sr as any).signalRService = { onIndexersUpdated: () => () => {} } as any } const IndexersTab = (await import('@/views/settings/IndexersTab.vue')).default - const wrapper = mount(IndexersTab, { global: { plugins: [pinia] } }) + const wrapper = mount(IndexersTab, { global: { plugins: [pinia], stubs: modalStubs } }) // Allow Vue to flush lifecycle effects await wrapper.vm.$nextTick() @@ -66,7 +68,7 @@ describe('IndexersTab', () => { // Resolve the pending API call and wait for the DOM to update resolveFn([]) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() await wrapper.vm.$nextTick() // After resolution, the empty-state should be shown (no indexers) @@ -80,7 +82,7 @@ describe('IndexersTab', () => { vi.doMock('@/services/api', async (importOriginal) => { const actual = await importOriginal() return { - ...(actual as unknown), + ...(actual as any), getIndexers: vi.fn().mockResolvedValue([]), getProwlarrImportSettings: vi.fn().mockResolvedValue({ url: 'http://localhost', @@ -93,20 +95,20 @@ describe('IndexersTab', () => { const sr = await import('@/services/signalr') if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { - ;(sr as unknown).signalRService = { onIndexersUpdated: () => () => {} } as unknown + ;(sr as any).signalRService = { onIndexersUpdated: () => () => {} } as any } const IndexersTab = (await import('@/views/settings/IndexersTab.vue')).default const wrapper = mount(IndexersTab, { attachTo: document.body, - global: { plugins: [pinia] }, + global: { plugins: [pinia], stubs: modalStubs }, }) - ;(wrapper.vm as unknown as { openProwlarrImport: () => void }).openProwlarrImport() + ;(wrapper.vm as any as { openProwlarrImport: () => void }).openProwlarrImport() await wrapper.vm.$nextTick() - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() await wrapper.vm.$nextTick() expect((wrapper.get('#prowlarr-url').element as HTMLInputElement).value).toBe( @@ -134,7 +136,7 @@ describe('IndexersTab', () => { vi.doMock('@/services/api', async (importOriginal) => { const actual = await importOriginal() return { - ...(actual as unknown), + ...(actual as any), getIndexers: vi.fn().mockResolvedValue([]), getProwlarrImportSettings: vi.fn().mockResolvedValue({ url: 'http://localhost', @@ -148,26 +150,24 @@ describe('IndexersTab', () => { const sr = await import('@/services/signalr') if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { - ;(sr as unknown).signalRService = { onIndexersUpdated: () => () => {} } as unknown + ;(sr as any).signalRService = { onIndexersUpdated: () => () => {} } as any } const IndexersTab = (await import('@/views/settings/IndexersTab.vue')).default const wrapper = mount(IndexersTab, { attachTo: document.body, - global: { plugins: [pinia] }, + global: { plugins: [pinia], stubs: modalStubs }, }) - ;(wrapper.vm as unknown as { openProwlarrImport: () => void }).openProwlarrImport() + ;(wrapper.vm as any as { openProwlarrImport: () => void }).openProwlarrImport() await wrapper.vm.$nextTick() - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() await wrapper.vm.$nextTick() await wrapper.get('#prowlarr-port').setValue('') - await ( - wrapper.vm as unknown as { importFromProwlarr: () => Promise } - ).importFromProwlarr() + await (wrapper.vm as any as { importFromProwlarr: () => Promise }).importFromProwlarr() expect(importProwlarrIndexers).toHaveBeenCalledWith({ url: 'http://localhost', @@ -190,7 +190,7 @@ describe('IndexersTab', () => { vi.doMock('@/services/api', async (importOriginal) => { const actual = await importOriginal() return { - ...(actual as unknown), + ...(actual as any), getIndexers: vi.fn().mockResolvedValue([]), getProwlarrImportSettings: vi.fn().mockResolvedValue({ url: 'http://localhost', @@ -204,25 +204,23 @@ describe('IndexersTab', () => { const sr = await import('@/services/signalr') if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { - ;(sr as unknown).signalRService = { onIndexersUpdated: () => () => {} } as unknown + ;(sr as any).signalRService = { onIndexersUpdated: () => () => {} } as any } const IndexersTab = (await import('@/views/settings/IndexersTab.vue')).default const wrapper = mount(IndexersTab, { attachTo: document.body, - global: { plugins: [pinia] }, + global: { plugins: [pinia], stubs: modalStubs }, }) - ;(wrapper.vm as unknown as { openProwlarrImport: () => void }).openProwlarrImport() + ;(wrapper.vm as any as { openProwlarrImport: () => void }).openProwlarrImport() await wrapper.vm.$nextTick() - await new Promise((resolve) => setTimeout(resolve, 0)) + await flushAsync() await wrapper.vm.$nextTick() - ;(wrapper.vm as unknown as { prowlarrPort: number }).prowlarrPort = 9696 + ;(wrapper.vm as any as { prowlarrPort: number }).prowlarrPort = 9696 - await ( - wrapper.vm as unknown as { importFromProwlarr: () => Promise } - ).importFromProwlarr() + await (wrapper.vm as any as { importFromProwlarr: () => Promise }).importFromProwlarr() expect(importProwlarrIndexers).toHaveBeenCalledWith({ url: 'http://localhost', diff --git a/fe/src/__tests__/NotificationsTab.spec.ts b/fe/src/views/settings/test/NotificationsTab.spec.ts similarity index 92% rename from fe/src/__tests__/NotificationsTab.spec.ts rename to fe/src/views/settings/test/NotificationsTab.spec.ts index f9a832deb..2ed56d3a8 100644 --- a/fe/src/__tests__/NotificationsTab.spec.ts +++ b/fe/src/views/settings/test/NotificationsTab.spec.ts @@ -19,6 +19,7 @@ import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { useConfigurationStore } from '@/stores/configuration' +import { modalStubs } from '@/test/stubs' describe('NotificationsTab', () => { it('shows loading state and header spinner while application settings are loading', async () => { @@ -31,7 +32,7 @@ describe('NotificationsTab', () => { const NotificationsTab = (await import('@/views/settings/NotificationsTab.vue')).default const wrapper = mount(NotificationsTab, { props: { settings: null }, - global: { plugins: [pinia] }, + global: { plugins: [pinia], stubs: modalStubs }, }) await wrapper.vm.$nextTick() @@ -50,12 +51,12 @@ describe('NotificationsTab', () => { const NotificationsTab = (await import('@/views/settings/NotificationsTab.vue')).default const wrapper = mount(NotificationsTab, { - props: { settings: { webhookUrl: '', webhooks: [] } }, - global: { plugins: [pinia] }, + props: { settings: { webhookUrl: '', webhooks: [] } as any }, + global: { plugins: [pinia], stubs: modalStubs }, }) // Open the webhook form and select NTFY type - const vm = wrapper.vm as unknown as { openWebhookForm: () => void } + const vm = wrapper.vm as any as { openWebhookForm: () => void } vm.openWebhookForm() await wrapper.vm.$nextTick() diff --git a/fe/src/__tests__/QualityProfilesTab.spec.ts b/fe/src/views/settings/test/QualityProfilesTab.spec.ts similarity index 77% rename from fe/src/__tests__/QualityProfilesTab.spec.ts rename to fe/src/views/settings/test/QualityProfilesTab.spec.ts index 096c6b87a..de2409349 100644 --- a/fe/src/__tests__/QualityProfilesTab.spec.ts +++ b/fe/src/views/settings/test/QualityProfilesTab.spec.ts @@ -18,24 +18,30 @@ import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' +import { baseStubs } from '@/test/stubs' +import { flushAsync } from '@/test/utils/wait' describe('QualityProfilesTab', () => { it('shows loading state while fetching profiles', async () => { + vi.resetModules() const pinia = createPinia() setActivePinia(pinia) - // Spy the API so we can control resolution - const api = await import('@/services/api') let resolveFn: (value: unknown) => void = () => {} - vi.spyOn(api, 'getQualityProfiles').mockImplementation( - () => - new Promise((res) => { - resolveFn = res - }) as unknown, - ) + vi.doMock('@/services/api', async (importOriginal) => ({ + ...((await importOriginal()) as object), + getQualityProfiles: vi.fn( + () => + new Promise((res) => { + resolveFn = res + }), + ), + })) const QualityProfilesTab = (await import('@/views/settings/QualityProfilesTab.vue')).default - const wrapper = mount(QualityProfilesTab, { global: { plugins: [pinia] } }) + const wrapper = mount(QualityProfilesTab, { + global: { plugins: [pinia], stubs: baseStubs }, + }) // debug: inspect rendered HTML during pending state await wrapper.vm.$nextTick() @@ -46,7 +52,7 @@ describe('QualityProfilesTab', () => { // resolve API and assert empty-state appears resolveFn([]) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() await wrapper.vm.$nextTick() expect(wrapper.find('.empty-state').exists()).toBe(true) }) diff --git a/fe/src/__tests__/SettingsView.spec.ts b/fe/src/views/test/SettingsView.spec.ts similarity index 63% rename from fe/src/__tests__/SettingsView.spec.ts rename to fe/src/views/test/SettingsView.spec.ts index cae9ca8f4..830fb7688 100644 --- a/fe/src/__tests__/SettingsView.spec.ts +++ b/fe/src/views/test/SettingsView.spec.ts @@ -19,10 +19,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Mock } from 'vitest' import { mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' -import { createMemoryHistory, createRouter } from 'vue-router' import SettingsView from '@/views/SettingsView.vue' import { apiService } from '@/services/api' +import { createDownloadClientConfiguration } from '@/test/factories/downloadClient' +import { createTestPinia, createTestRouter, mountWithPiniaAndRouter } from '@/test/utils/mount' +import { flushAsync } from '@/test/utils/wait' + +const mockAuthStore = vi.hoisted(() => ({ + user: { authenticated: true }, + redirectTo: null as string | null, + loadCurrentUser: vi.fn(async () => undefined), +})) vi.mock('@/services/api', () => ({ apiService: { @@ -56,6 +63,10 @@ vi.mock('@/services/api', () => ({ deleteRemotePathMapping: vi.fn(async () => ({})), })) +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => mockAuthStore, +})) + describe('SettingsView', () => { type SetupState = { showPassword?: { value: boolean } | boolean } type Settings = { @@ -66,54 +77,30 @@ describe('SettingsView', () => { usProxyUsername?: string usProxyPassword?: string } - type DownloadClient = { - id: string - name: string - type: string - host: string - port: number - isEnabled: boolean - useSSL: boolean - downloadPath: string - } beforeEach(() => { ;(apiService.getStartupConfig as Mock).mockReset() + mockAuthStore.user.authenticated = true + mockAuthStore.redirectTo = null + mockAuthStore.loadCurrentUser.mockClear() // Provide a single Pinia instance for stores used by the component - const pinia = createPinia() - setActivePinia(pinia) + createTestPinia() }) it('sets authEnabled when startup config AuthenticationRequired is Enabled', async () => { ;(apiService.getStartupConfig as Mock).mockResolvedValue({ AuthenticationRequired: 'Enabled' }) - // create a minimal router for components that inject router/location - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', name: 'home', component: { template: '
' } }], - }) - // Ensure router is ready before mounting (SettingsView may call router.replace during mount) - await router.push('/') - await router.isReady().catch(() => {}) - const pinia = createPinia() - const wrapper = mount(SettingsView, { - global: { plugins: [pinia, router], stubs: ['FolderBrowser'] }, + const wrapper = await mountWithPiniaAndRouter(SettingsView, { + global: { stubs: ['FolderBrowser'] }, }) // Wait for onMounted async calls to finish - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Accept both legacy 'Enabled' and new 'true' string values - const vm = wrapper.vm as unknown as { authEnabled?: boolean } + const vm = wrapper.vm as any as { authEnabled?: boolean } expect(vm.authEnabled).toBe(true) }) it('toggles password visibility', async () => { - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', name: 'home', component: { template: '
' } }], - }) - await router.push('/') - await router.isReady().catch(() => {}) - const pinia = createPinia() - const wrapper = mount(SettingsView, { - global: { plugins: [pinia, router], stubs: ['FolderBrowser'] }, + const wrapper = await mountWithPiniaAndRouter(SettingsView, { + global: { stubs: ['FolderBrowser'] }, }) // Activate the General Settings tab so the password field is rendered const generalTab = wrapper @@ -122,45 +109,30 @@ describe('SettingsView', () => { expect(generalTab).toBeTruthy() await generalTab!.trigger('click') // Provide settings so the admin password input is rendered - const vm = wrapper.vm as unknown as { + const vm = wrapper.vm as any as { settings?: Settings $?: { setupState?: SetupState } $setup?: SetupState toggleShowPassword?: () => void } vm.settings = { adminPassword: 'secret' } - await wrapper.vm.$nextTick() - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Access internal setup state to check showPassword directly (more reliable in VTU) - const setupState = vm.$?.setupState ?? vm.$setup ?? (vm as unknown as SetupState) + const setupState = vm.$?.setupState ?? vm.$setup ?? (vm as any as SetupState) // initial value should be false - expect( - (setupState.showPassword as unknown)?.value ?? (setupState.showPassword as unknown), - ).toBe(false) + expect((setupState.showPassword as any)?.value ?? (setupState.showPassword as any)).toBe(false) // Toggle via exposed function vm.toggleShowPassword?.() - await wrapper.vm.$nextTick() - expect( - (setupState.showPassword as unknown)?.value ?? (setupState.showPassword as unknown), - ).toBe(true) + await flushAsync() + expect((setupState.showPassword as any)?.value ?? (setupState.showPassword as any)).toBe(true) }) // Note: legacy "Prefer US domain" setting was removed from the UI; // related tests removed to reflect current application state. it('applies child updates (via events) to settings and includes them when saving', async () => { - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', name: 'home', component: { template: '
' } }], - }) - await router.push('/') - await router.isReady().catch(() => {}) - - const pinia = createPinia() - setActivePinia(pinia) - - const wrapper = mount(SettingsView, { - global: { plugins: [pinia, router], stubs: ['FolderBrowser'] }, + const wrapper = await mountWithPiniaAndRouter(SettingsView, { + global: { stubs: ['FolderBrowser'] }, }) // Activate General Settings tab and provide initial settings @@ -170,20 +142,19 @@ describe('SettingsView', () => { expect(generalTab).toBeTruthy() await generalTab!.trigger('click') - const vm = wrapper.vm as unknown as { settings?: Settings } + const vm = wrapper.vm as any as { settings?: Settings } vm.settings = { folderNamingPattern: '{Author}/{Series}/{Title}', fileNamingPattern: '{Title}', - } as unknown as Settings + } as any as Settings - await wrapper.vm.$nextTick() - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Find the File Naming Pattern input inside the child and change it const fileNamingInput = wrapper.find('input[placeholder="{Title}"]') expect(fileNamingInput.exists()).toBe(true) await fileNamingInput.setValue('{Title}-{DiskNumber}') - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Spy on the configuration store save method const { useConfigurationStore } = await import('@/stores/configuration') @@ -203,37 +174,30 @@ describe('SettingsView', () => { }) it('toggles download client enabled state', async () => { - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', name: 'home', component: { template: '
' } }], - }) - await router.push('/') - await router.isReady().catch(() => {}) - - const pinia = createPinia() - setActivePinia(pinia) + const pinia = createTestPinia() + const { router, ready } = createTestRouter() + await ready() // Prepare configuration store with a single disabled client const { useConfigurationStore } = await import('@/stores/configuration') const cfgStore = useConfigurationStore() - cfgStore.downloadClientConfigurations = [] as unknown - cfgStore.downloadClientConfigurations.push({ - id: 'client-1', - name: 'Test Client', - type: 'qbittorrent', - host: 'localhost', - port: 8080, - isEnabled: false, - useSSL: false, - downloadPath: '', - } as unknown) + cfgStore.downloadClientConfigurations = [] + cfgStore.downloadClientConfigurations.push( + createDownloadClientConfiguration({ + id: 'client-1', + name: 'Test Client', + host: 'localhost', + port: 8080, + isEnabled: false, + }), + ) // Prevent load from overwriting our test data cfgStore.loadDownloadClientConfigurations = vi.fn(async () => {}) - cfgStore.saveDownloadClientConfiguration = vi.fn(async (c: Partial) => { + cfgStore.saveDownloadClientConfiguration = vi.fn(async (c) => { // Simulate backend saving (no-op) - cfgStore.downloadClientConfigurations[0] = c as unknown + cfgStore.downloadClientConfigurations[0] = c as any return Promise.resolve() }) @@ -247,42 +211,31 @@ describe('SettingsView', () => { .find((b) => b.text().includes('Download Clients')) expect(clientsTab).toBeTruthy() await clientsTab!.trigger('click') - await wrapper.vm.$nextTick() + await flushAsync() // Call the toggle handler directly (avoid relying on rendered DOM in VTU) - const vm2 = wrapper.vm as unknown as { - toggleDownloadClientFunc?: (c: DownloadClient) => Promise + const vm2 = wrapper.vm as any as { + toggleDownloadClientFunc?: ( + c: ReturnType, + ) => Promise } await vm2.toggleDownloadClientFunc?.(cfgStore.downloadClientConfigurations[0]) // Wait for async save - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() expect(cfgStore.saveDownloadClientConfiguration).toHaveBeenCalled() expect(cfgStore.downloadClientConfigurations[0].isEnabled).toBe(true) }) it('renders Root Folders in its own tab', async () => { - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', name: 'home', component: { template: '
' } }], - }) - await router.push('/') - await router.isReady().catch(() => {}) - - const pinia = createPinia() - setActivePinia(pinia) - - const wrapper = mount(SettingsView, { - global: { plugins: [pinia, router], stubs: ['FolderBrowser'] }, + const wrapper = await mountWithPiniaAndRouter(SettingsView, { + global: { stubs: ['FolderBrowser'] }, }) const tab = wrapper.findAll('button.tab-button').find((b) => b.text().includes('Root Folders')) expect(tab).toBeTruthy() await tab!.trigger('click') - // Wait for router navigation to complete and nextTick - await router.isReady().catch(() => {}) - await new Promise((r) => setTimeout(r, 0)) - await wrapper.vm.$nextTick() + await flushAsync() // Ensure the Root Folders tab became active expect(tab!.classes()).toContain('active') diff --git a/fe/src/__tests__/import-activity.spec.ts b/fe/src/views/test/import-activity.spec.ts similarity index 97% rename from fe/src/__tests__/import-activity.spec.ts rename to fe/src/views/test/import-activity.spec.ts index 73e9d8c43..b72782f84 100644 --- a/fe/src/__tests__/import-activity.spec.ts +++ b/fe/src/views/test/import-activity.spec.ts @@ -24,7 +24,7 @@ describe('import checks', () => { const fs = await import('fs') const path = await import('path') const compiler = await import('@vue/compiler-sfc') - const filePath = path.resolve(__dirname, '../views/ActivityView.vue') + const filePath = path.resolve(__dirname, '../ActivityView.vue') const content = fs.readFileSync(filePath, 'utf-8') const parsed = compiler.parse(content) // If parse returns a descriptor, try to compile template (if present) to catch template errors diff --git a/fe/tsconfig.app.json b/fe/tsconfig.app.json index 913b8f279..64a66f5e5 100644 --- a/fe/tsconfig.app.json +++ b/fe/tsconfig.app.json @@ -1,7 +1,7 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], - "exclude": ["src/**/__tests__/*"], + "exclude": ["src/**/test/**/*"], "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", diff --git a/fe/tsconfig.node.json b/fe/tsconfig.node.json index a83dfc9d4..131e3f9c3 100644 --- a/fe/tsconfig.node.json +++ b/fe/tsconfig.node.json @@ -3,9 +3,9 @@ "include": [ "vite.config.*", "vitest.config.*", + "vitest.projects.ts", + "vitest.*.config.*", "cypress.config.*", - "nightwatch.conf.*", - "playwright.config.*", "eslint.config.*" ], "compilerOptions": { diff --git a/fe/tsconfig.vitest.json b/fe/tsconfig.vitest.json index bf41d4c8f..543a5c708 100644 --- a/fe/tsconfig.vitest.json +++ b/fe/tsconfig.vitest.json @@ -1,11 +1,11 @@ { "extends": "./tsconfig.app.json", - "include": ["src/**/__tests__/*", "env.d.ts"], + "include": ["src/**/test/**/*", "env.d.ts"], "exclude": [], "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", - "lib": [], + "lib": ["ES2023", "DOM", "DOM.Iterable"], // Include vitest globals so test files can use describe/test/expect without // requiring additional @types packages during build-time typechecking. "types": ["vitest/globals", "node", "jsdom"], diff --git a/fe/vite.config.ts b/fe/vite.config.ts index d5328db6c..1411fdd60 100644 --- a/fe/vite.config.ts +++ b/fe/vite.config.ts @@ -4,27 +4,37 @@ import type { ServerResponse } from 'node:http' import { defineConfig } from 'vite' import type { PluginOption } from 'vite' import vue from '@vitejs/plugin-vue' -// Visualizer for bundle analysis. We cast to any when injecting to avoid -// TypeScript plugin signature mismatches between rollup and vite types. +// Visualizer for bundle analysis. We cast when injecting to avoid TypeScript +// plugin signature mismatches between Rollup and Vite types. import { visualizer } from 'rollup-plugin-visualizer' // https://vite.dev/config/ export default defineConfig(({ mode }) => ({ plugins: [ vue(), - // Generate a static treemap report after build - // cast to any to satisfy TypeScript when mixing rollup plugin types with Vite - // cast plugin to any to avoid Vite/TS signature issues - // Visualizer returns a Rollup plugin. Cast via unknown -> Plugin to avoid explicit `any`. - (visualizer({ filename: 'dist/stats.html', title: 'Listenarr bundle analysis', open: false }) as unknown as PluginOption), + // Visualizer returns a Rollup plugin. Cast via unknown to avoid explicit `any`. + (visualizer({ filename: 'dist/stats.html', title: 'Listenarr bundle analysis', open: false }) as unknown as PluginOption), ], build: { // Generate sourcemaps for bundle analysis tools (source-map-explorer) sourcemap: true, - }, - esbuild: { - // Remove console.log and debugger statements from production builds - drop: mode === 'production' ? ['console', 'debugger'] : [], + minify: 'oxc', + ...(mode === 'production' + ? { + rolldownOptions: { + output: { + minify: { + compress: { + dropConsole: true, + dropDebugger: true, + }, + mangle: true, + codegen: true, + }, + }, + }, + } + : {}), }, resolve: { alias: { diff --git a/fe/vitest.config.ts b/fe/vitest.config.ts index a5a7b9a9f..8f0758108 100644 --- a/fe/vitest.config.ts +++ b/fe/vitest.config.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from 'node:url' import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' import viteConfig from './vite.config' +import { testProjects, testRoot } from './vitest.projects' export default defineConfig((configEnv) => mergeConfig( @@ -12,13 +13,34 @@ export default defineConfig((configEnv) => }, }, test: { - environment: 'jsdom', - setupFiles: './src/__tests__/test-setup.ts', + execArgv: ['--no-warnings'], + setupFiles: ['src/test/setup/signalr.ts'], + projects: testProjects, // Increase global test timeout to reduce flaky timeouts in CI/local runs testTimeout: 10000, // Exclude e2e and cypress test files from unit test runs exclude: [...configDefaults.exclude, 'e2e/**', 'cypress/**'], - root: fileURLToPath(new URL('./', import.meta.url)), + root: testRoot, + coverage: { + provider: 'v8', + reportsDirectory: 'coverage/unit', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,vue}'], + exclude: [ + ...configDefaults.coverage.exclude, + 'src/**/test/**', + 'src/test/**', + 'src/**/*.d.ts', + 'src/env.d.ts', + 'src/main.ts', + ], + thresholds: { + branches: 30, + functions: 30, + lines: 40, + statements: 40, + }, + }, }, }, ), diff --git a/fe/vitest.no-setup.config.ts b/fe/vitest.no-setup.config.ts deleted file mode 100644 index a7fcd04d4..000000000 --- a/fe/vitest.no-setup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - environment: 'jsdom', - setupFiles: [], - root: fileURLToPath(new URL('./', import.meta.url)), - }, -}) diff --git a/fe/vitest.projects.ts b/fe/vitest.projects.ts new file mode 100644 index 000000000..70c195cd4 --- /dev/null +++ b/fe/vitest.projects.ts @@ -0,0 +1,49 @@ +import { fileURLToPath } from 'node:url' +import { configDefaults, type TestProjectConfiguration } from 'vitest/config' + +export const testRoot = fileURLToPath(new URL('./', import.meta.url)) + +export const testExclude = [...configDefaults.exclude, 'e2e/**', 'cypress/**'] + +export const jsdomEnvironment = { + environment: 'jsdom' as const, + environmentOptions: { + jsdom: { + url: 'http://localhost/', + }, + }, +} + +export const jsdomTestGlobs = ['src/**/test/**/*.spec.ts'] + +export const nodeTestGlobs = ['src/**/test/**/*.node.spec.ts'] + +export const smokeTestGlobs = ['src/test/smoke/**/*.spec.ts'] + +export const testProjects: TestProjectConfiguration[] = [ + { + extends: true, + test: { + name: 'unit-node', + environment: 'node', + include: nodeTestGlobs, + }, + }, + { + extends: true, + test: { + ...jsdomEnvironment, + name: 'unit-jsdom', + include: jsdomTestGlobs, + exclude: [...testExclude, ...nodeTestGlobs, ...smokeTestGlobs], + }, + }, + { + extends: true, + test: { + ...jsdomEnvironment, + name: 'smoke', + include: smokeTestGlobs, + }, + }, +] diff --git a/package.json b/package.json index 57096e834..6e2af68aa 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "install:all": "cd fe && npm install", "lint": "npm run lint:frontend", "lint:frontend": "cd fe && npm run lint:check", + "verify:frontend": "cd fe && npm run verify", "lint:staged": "node scripts/lint-staged.mjs", "lint:fix": "cd fe && npm run lint:fix", "format": "npm run format:backend && npm run format:frontend", diff --git a/scripts/lint-staged.mjs b/scripts/lint-staged.mjs index bb8714a17..235629412 100644 --- a/scripts/lint-staged.mjs +++ b/scripts/lint-staged.mjs @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process' +import path from 'node:path' -const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx' +const localBin = (packageName, binPath) => path.join('node_modules', packageName, binPath) const run = (command, args, options = {}) => { const result = spawnSync(command, args, { @@ -10,7 +11,7 @@ const run = (command, args, options = {}) => { }) if (result.error) { - console.error(result.error.message) + console.error(`Failed to run "${command}": ${result.error.message}`) process.exit(1) } @@ -71,7 +72,7 @@ if (backendFiles.length > 0) { if (frontendLintFiles.length > 0) { console.log('Checking staged frontend lint rules...') - run(npxCommand, ['eslint', ...frontendLintFiles], { cwd: 'fe' }) + run('node', [localBin('eslint', 'bin/eslint.js'), ...frontendLintFiles], { cwd: 'fe' }) } if (frontendVueFiles.length > 0) { @@ -81,5 +82,9 @@ if (frontendVueFiles.length > 0) { if (frontendFormatFiles.length > 0) { console.log('Checking staged frontend formatting...') - run(npxCommand, ['prettier', '--check', ...frontendFormatFiles], { cwd: 'fe' }) -} + run( + 'node', + [localBin('prettier', 'bin/prettier.cjs'), '--check', ...frontendFormatFiles], + { cwd: 'fe' }, + ) +} \ No newline at end of file