diff --git a/.gitignore b/.gitignore index 3c64b2278..21d126723 100644 --- a/.gitignore +++ b/.gitignore @@ -128,7 +128,7 @@ coverage/ /cypress/videos/ /cypress/screenshots/ test-results/ -playwright-report/ +eslint-report.json .codeql-db*/ # Vite @@ -277,5 +277,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-push b/.husky/pre-push index 443389d6a..9d2200f7d 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -9,6 +9,12 @@ fi echo "Syncing version..." node scripts/sync-fe-version-from-csproj.mjs +if ! git diff --quiet -- package.json package-lock.json fe/package.json fe/package-lock.json; then + echo "Version sync changed package metadata. Commit those changes before pushing." + git diff -- package.json package-lock.json fe/package.json fe/package-lock.json + exit 1 +fi + echo "Checking backend format..." dotnet format listenarr.slnx --no-restore --verify-no-changes --verbosity minimal diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 114a0a08e..5059262b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,7 +145,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 386ccbd7e..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/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 f00b0219c..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/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 8c5f82139..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/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 91b91b5e9..193c483be 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -25,6 +25,7 @@ "@types/jsdom": "^28.0.3", "@types/node": "^24.13.1", "@vitejs/plugin-vue": "^6.0.7", + "@vitest/coverage-v8": "^4.1.8", "@vitest/eslint-plugin": "^1.6.19", "@vue/compiler-sfc": "^3.5.35", "@vue/eslint-config-prettier": "^10.2.0", @@ -52,17 +53,8 @@ "node": ">=24.0.0" } }, - "..": { - "version": "1.0.0", - "extraneous": true, - "dependencies": { - "concurrently": "^9.2.1" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { @@ -78,8 +70,6 @@ }, "node_modules/@asamuzakjp/dom-selector": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -95,8 +85,6 @@ }, "node_modules/@asamuzakjp/generational-cache": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, "license": "MIT", "engines": { @@ -105,8 +93,6 @@ }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, "license": "MIT" }, @@ -219,10 +205,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "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", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, "license": "MIT", "dependencies": { @@ -234,8 +226,6 @@ }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -254,8 +244,6 @@ }, "node_modules/@csstools/css-calc": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -278,8 +266,6 @@ }, "node_modules/@csstools/css-color-parser": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -306,8 +292,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -354,8 +338,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -403,8 +385,6 @@ }, "node_modules/@cypress/xvfb": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -414,8 +394,6 @@ }, "node_modules/@cypress/xvfb/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -455,8 +433,6 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -474,8 +450,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -484,8 +458,6 @@ }, "node_modules/@eslint/config-array": { "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -499,8 +471,6 @@ }, "node_modules/@eslint/config-array/node_modules/balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { @@ -508,9 +478,7 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.5", "dev": true, "license": "MIT", "dependencies": { @@ -522,8 +490,6 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -564,8 +530,6 @@ }, "node_modules/@eslint/object-schema": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -588,8 +552,6 @@ }, "node_modules/@exodus/bytes": { "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "license": "MIT", "engines": { @@ -639,9 +601,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", - "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -660,8 +622,6 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -670,8 +630,6 @@ }, "node_modules/@humanfs/node": { "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -684,8 +642,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -698,8 +654,6 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -712,8 +666,6 @@ }, "node_modules/@isaacs/cliui": { "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "license": "ISC", "dependencies": { @@ -730,8 +682,6 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -743,8 +693,6 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -756,15 +704,11 @@ }, "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { @@ -781,8 +725,6 @@ }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { @@ -797,8 +739,6 @@ }, "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -815,8 +755,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -825,8 +763,6 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -835,8 +771,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -844,14 +778,10 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -860,14 +790,10 @@ }, "node_modules/@material/material-color-utilities": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz", - "integrity": "sha512-dlq6VExJReb8dhjj3a/yTigr3ncNwoFmL5Iy2ENtbDX03EmNeOEdZ+vsaGrj7RTuO+mB7L58II4LCsl4NpM8uw==", "license": "Apache-2.0" }, "node_modules/@microsoft/signalr": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", - "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -878,13 +804,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -897,8 +823,6 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -911,8 +835,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -921,8 +843,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -935,8 +855,6 @@ }, "node_modules/@one-ini/wasm": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", "dev": true, "license": "MIT" }, @@ -952,8 +870,6 @@ }, "node_modules/@phosphor-icons/vue": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@phosphor-icons/vue/-/vue-2.2.1.tgz", - "integrity": "sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==", "license": "MIT", "engines": { "node": ">=14" @@ -964,8 +880,6 @@ }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", "optional": true, @@ -975,8 +889,6 @@ }, "node_modules/@pkgr/core": { "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true, "license": "MIT", "engines": { @@ -1297,15 +1209,11 @@ }, "node_modules/@types/esrecurse": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -1322,6 +1230,11 @@ "undici-types": "^7.21.0" } }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.22.0", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsesc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", @@ -1336,68 +1249,51 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", - "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, - "node_modules/@types/node/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", - "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", "dev": true, "license": "MIT" }, "node_modules/@types/sizzle": { "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", - "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", "dev": true, "license": "MIT" }, "node_modules/@types/tmp": { "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", "dev": true, "license": "MIT" }, "node_modules/@types/tough-cookie": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true, "license": "MIT" }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1410,15 +1306,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -1426,16 +1320,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -1451,14 +1345,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -1473,14 +1367,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1491,9 +1385,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -1508,15 +1402,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1533,9 +1427,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -1547,16 +1441,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1614,16 +1508,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1638,13 +1532,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1685,10 +1579,41 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "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.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/eslint-plugin": { - "version": "1.6.19", - "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.19.tgz", - "integrity": "sha512-zodmXRsVKFsuHxHJILuTFaaKsrsxm0YsiOX65clk+LpCW9JrVXaf6ERXr0caDs+NEk0S62Jyk0K7XYQ7gWXheA==", + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.20.tgz", + "integrity": "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1717,16 +1642,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1735,13 +1660,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1772,9 +1697,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -1785,13 +1710,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -1799,14 +1724,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1815,9 +1740,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -1825,13 +1750,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1841,8 +1766,6 @@ }, "node_modules/@volar/language-core": { "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1851,15 +1774,11 @@ }, "node_modules/@volar/source-map": { "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, "license": "MIT", "dependencies": { @@ -1870,8 +1789,6 @@ }, "node_modules/@vue-macros/common": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", - "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", "license": "MIT", "dependencies": { "@vue/compiler-sfc": "^3.5.22", @@ -1896,39 +1813,39 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", - "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/shared": "3.5.35", + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", - "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.35", - "@vue/shared": "3.5.35" + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", - "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/compiler-core": "3.5.35", - "@vue/compiler-dom": "3.5.35", - "@vue/compiler-ssr": "3.5.35", - "@vue/shared": "3.5.35", + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", @@ -1936,19 +1853,17 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", - "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.35", - "@vue/shared": "3.5.35" + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/devtools-api": { "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", "license": "MIT", "dependencies": { "@vue/devtools-kit": "^7.7.9" @@ -1956,8 +1871,6 @@ }, "node_modules/@vue/devtools-kit": { "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", "license": "MIT", "dependencies": { "@vue/devtools-shared": "^7.7.9", @@ -1971,8 +1884,6 @@ }, "node_modules/@vue/devtools-shared": { "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", "license": "MIT", "dependencies": { "rfdc": "^1.4.1" @@ -1980,8 +1891,6 @@ }, "node_modules/@vue/eslint-config-prettier": { "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", - "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", "dev": true, "license": "MIT", "dependencies": { @@ -2020,9 +1929,9 @@ } }, "node_modules/@vue/language-core": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.3.tgz", - "integrity": "sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.5.tgz", + "integrity": "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==", "dev": true, "license": "MIT", "dependencies": { @@ -2049,53 +1958,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", - "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.35" + "@vue/shared": "3.5.38" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", - "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.35", - "@vue/shared": "3.5.35" + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", - "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.35", - "@vue/runtime-core": "3.5.35", - "@vue/shared": "3.5.35", + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", - "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.35", - "@vue/shared": "3.5.35" + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" }, "peerDependencies": { - "vue": "3.5.35" + "vue": "3.5.38" } }, "node_modules/@vue/shared": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", - "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -2121,8 +2030,6 @@ }, "node_modules/@vue/tsconfig": { "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", - "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2178,15 +2085,11 @@ }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/abbrev": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "dev": true, "license": "ISC", "engines": { @@ -2195,8 +2098,6 @@ }, "node_modules/abort-controller": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -2207,8 +2108,6 @@ }, "node_modules/acorn": { "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2219,8 +2118,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2265,8 +2162,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { @@ -2275,8 +2170,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -2291,8 +2184,6 @@ }, "node_modules/arch": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", "dev": true, "funding": [ { @@ -2312,8 +2203,6 @@ }, "node_modules/arg": { "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true, "license": "MIT" }, @@ -2349,8 +2238,6 @@ }, "node_modules/ast-kit": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", - "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", @@ -2363,6 +2250,24 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/ast-walker-scope": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", @@ -2389,8 +2294,6 @@ }, "node_modules/at-least-node": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, "license": "ISC", "engines": { @@ -2415,9 +2318,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", - "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "dev": true, "license": "MIT", "dependencies": { @@ -2439,15 +2342,11 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, "funding": [ { @@ -2477,8 +2376,6 @@ }, "node_modules/bidi-js": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", "dependencies": { @@ -2487,8 +2384,6 @@ }, "node_modules/birpc": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -2496,29 +2391,21 @@ }, "node_modules/blob-util": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/bluebird": { "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true, "license": "MIT" }, "node_modules/boolbase": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true, "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.0.3", "dev": true, "license": "MIT", "dependencies": { @@ -2527,8 +2414,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2540,8 +2425,6 @@ }, "node_modules/buffer": { "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { @@ -2565,8 +2448,6 @@ }, "node_modules/bundle-name": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2581,8 +2462,6 @@ }, "node_modules/cachedir": { "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", "dev": true, "license": "MIT", "engines": { @@ -2591,8 +2470,6 @@ }, "node_modules/call-bind": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -2610,8 +2487,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2623,8 +2498,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -2656,8 +2529,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -2673,8 +2544,6 @@ }, "node_modules/chalk/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": { @@ -2686,8 +2555,6 @@ }, "node_modules/check-more-types": { "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", "dev": true, "license": "MIT", "engines": { @@ -2696,8 +2563,6 @@ }, "node_modules/chokidar": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -2711,8 +2576,6 @@ }, "node_modules/ci-info": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -2743,8 +2606,6 @@ }, "node_modules/cli-table3": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", - "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", "dev": true, "license": "MIT", "dependencies": { @@ -2891,8 +2752,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2904,8 +2763,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -2918,8 +2775,6 @@ }, "node_modules/colors": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", "dev": true, "license": "MIT", "optional": true, @@ -2942,8 +2797,6 @@ }, "node_modules/commander": { "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, "license": "MIT", "engines": { @@ -2952,8 +2805,6 @@ }, "node_modules/common-tags": { "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "dev": true, "license": "MIT", "engines": { @@ -3013,14 +2864,10 @@ }, "node_modules/confbox": { "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, "node_modules/config-chain": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3030,8 +2877,6 @@ }, "node_modules/config-chain/node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, "license": "ISC" }, @@ -3044,8 +2889,6 @@ }, "node_modules/copy-anything": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", "license": "MIT", "dependencies": { "is-what": "^5.2.0" @@ -3066,8 +2909,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -3081,8 +2922,6 @@ }, "node_modules/css-tree": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { @@ -3095,8 +2934,6 @@ }, "node_modules/cssesc": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", "bin": { @@ -3113,9 +2950,9 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "15.16.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.16.0.tgz", - "integrity": "sha512-fy0M0c9xDLEp4v9y7LLKFeAQhIdDsobxDSKpD3JcZpqQefjy9TSzEyVV3HA0zu7hUi0bGHlSYlI7ASub8wgR9A==", + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.17.0.tgz", + "integrity": "sha512-WL5Gcqi1GaDWozBwXmkSAtOPafTsVSRS764iX6xvuz3DPzvBAxbkRyEi4BreVdVWxLDpiYRgZCyJUafBw44njw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3169,8 +3006,6 @@ }, "node_modules/cypress/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, "license": "0BSD" }, @@ -3189,8 +3024,6 @@ }, "node_modules/data-urls": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { @@ -3203,15 +3036,11 @@ }, "node_modules/dayjs": { "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3228,22 +3057,16 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/default-browser": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -3259,8 +3082,6 @@ }, "node_modules/default-browser-id": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { @@ -3272,8 +3093,6 @@ }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3290,8 +3109,6 @@ }, "node_modules/define-lazy-prop": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, "license": "MIT", "engines": { @@ -3313,8 +3130,6 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -3323,8 +3138,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3337,8 +3150,6 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, "license": "MIT" }, @@ -3355,8 +3166,6 @@ }, "node_modules/editorconfig": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", - "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", "dev": true, "license": "MIT", "dependencies": { @@ -3374,8 +3183,6 @@ }, "node_modules/editorconfig/node_modules/commander": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, "license": "MIT", "engines": { @@ -3384,15 +3191,11 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", "dependencies": { @@ -3401,8 +3204,6 @@ }, "node_modules/entities": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3426,8 +3227,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3435,8 +3234,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3444,15 +3241,11 @@ }, "node_modules/es-module-lexer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3479,8 +3272,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -3489,8 +3280,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -3501,11 +3290,14 @@ } }, "node_modules/eslint": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", - "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3558,8 +3350,6 @@ }, "node_modules/eslint-config-prettier": { "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -3593,8 +3383,6 @@ }, "node_modules/eslint-plugin-prettier": { "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { @@ -3656,8 +3444,6 @@ }, "node_modules/eslint-scope": { "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3675,8 +3461,6 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3688,8 +3472,6 @@ }, "node_modules/eslint/node_modules/ajv": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3705,8 +3487,6 @@ }, "node_modules/eslint/node_modules/balanced-match": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, "license": "MIT", "engines": { @@ -3714,9 +3494,7 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.5", "dev": true, "license": "MIT", "dependencies": { @@ -3728,8 +3506,6 @@ }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3741,15 +3517,11 @@ }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/minimatch": { "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -3764,8 +3536,6 @@ }, "node_modules/espree": { "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3782,8 +3552,6 @@ }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3795,8 +3563,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3808,8 +3574,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3821,8 +3585,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3831,14 +3593,10 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3847,8 +3605,6 @@ }, "node_modules/event-target-shim": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", "engines": { "node": ">=6" @@ -3856,8 +3612,6 @@ }, "node_modules/eventemitter2": { "version": "6.4.7", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", - "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", "dev": true, "license": "MIT" }, @@ -3870,8 +3624,6 @@ }, "node_modules/eventsource": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -3879,8 +3631,6 @@ }, "node_modules/execa": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", "dev": true, "license": "MIT", "dependencies": { @@ -3903,8 +3653,6 @@ }, "node_modules/executable": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", "dev": true, "license": "MIT", "dependencies": { @@ -3916,8 +3664,6 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3926,8 +3672,6 @@ }, "node_modules/exsolve": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, "node_modules/extend": { @@ -3949,22 +3693,16 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true, "license": "Apache-2.0" }, "node_modules/fast-glob": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -3980,8 +3718,6 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -3993,22 +3729,16 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fastq": { "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -4017,8 +3747,6 @@ }, "node_modules/fetch-cookie": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", - "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", "license": "Unlicense", "dependencies": { "set-cookie-parser": "^2.4.8", @@ -4027,8 +3755,6 @@ }, "node_modules/fetch-cookie/node_modules/tough-cookie": { "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.33", @@ -4042,8 +3768,6 @@ }, "node_modules/fetch-cookie/node_modules/universalify": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -4051,8 +3775,6 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4064,8 +3786,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -4077,8 +3797,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -4094,8 +3812,6 @@ }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", - "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4104,8 +3820,6 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -4118,8 +3832,6 @@ }, "node_modules/flatted": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4146,8 +3858,6 @@ }, "node_modules/foreground-child": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { @@ -4163,8 +3873,6 @@ }, "node_modules/foreground-child/node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", "engines": { @@ -4185,17 +3893,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4203,8 +3911,6 @@ }, "node_modules/fs-extra": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4233,8 +3939,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4242,8 +3946,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", "engines": { @@ -4251,9 +3953,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -4265,8 +3967,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4289,8 +3989,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4302,8 +4000,6 @@ }, "node_modules/get-stream": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "license": "MIT", "dependencies": { @@ -4328,9 +4024,6 @@ }, "node_modules/glob": { "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4350,8 +4043,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -4363,8 +4054,6 @@ }, "node_modules/global-dirs": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", "dev": true, "license": "MIT", "dependencies": { @@ -4392,8 +4081,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4404,15 +4091,11 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -4421,8 +4104,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -4434,8 +4115,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4462,8 +4141,6 @@ }, "node_modules/hasha": { "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4478,9 +4155,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4491,14 +4168,10 @@ }, "node_modules/hookable": { "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -4508,6 +4181,11 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "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", @@ -4539,8 +4217,6 @@ }, "node_modules/human-signals": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4549,8 +4225,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "dev": true, "funding": [ { @@ -4570,8 +4244,6 @@ }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -4580,8 +4252,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -4590,8 +4260,6 @@ }, "node_modules/ini": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", "dev": true, "license": "ISC", "engines": { @@ -4600,8 +4268,6 @@ }, "node_modules/is-docker": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, "license": "MIT", "bin": { @@ -4616,8 +4282,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -4626,8 +4290,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -4636,8 +4298,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -4649,8 +4309,6 @@ }, "node_modules/is-in-ssh": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "dev": true, "license": "MIT", "engines": { @@ -4662,8 +4320,6 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, "license": "MIT", "dependencies": { @@ -4681,8 +4337,6 @@ }, "node_modules/is-inside-container/node_modules/is-docker": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, "license": "MIT", "bin": { @@ -4697,8 +4351,6 @@ }, "node_modules/is-installed-globally": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4714,8 +4366,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { @@ -4724,8 +4374,6 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", "engines": { @@ -4734,15 +4382,11 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", "engines": { @@ -4761,8 +4405,6 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", "engines": { @@ -4774,8 +4416,6 @@ }, "node_modules/is-what": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", "license": "MIT", "engines": { "node": ">=18" @@ -4786,8 +4426,6 @@ }, "node_modules/is-wsl": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, "license": "MIT", "dependencies": { @@ -4799,15 +4437,11 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, @@ -4818,10 +4452,52 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "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", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "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", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -4865,8 +4541,6 @@ }, "node_modules/js-beautify": { "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", "dev": true, "license": "MIT", "dependencies": { @@ -4886,9 +4560,15 @@ } }, "node_modules/js-cookie": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", - "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "version": "3.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", "dev": true, "license": "MIT" }, @@ -4942,8 +4622,6 @@ }, "node_modules/jsdom/node_modules/tldts": { "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", "dev": true, "license": "MIT", "dependencies": { @@ -4955,15 +4633,11 @@ }, "node_modules/jsdom/node_modules/tldts-core": { "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", "dev": true, "license": "MIT" }, "node_modules/jsdom/node_modules/tough-cookie": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4975,8 +4649,6 @@ }, "node_modules/jsdom/node_modules/xml-name-validator": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4997,15 +4669,11 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", - "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", "dev": true, "license": "MIT", "engines": { @@ -5021,8 +4689,6 @@ }, "node_modules/json-stable-stringify": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", - "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", "dev": true, "license": "MIT", "dependencies": { @@ -5041,8 +4707,6 @@ }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, @@ -5055,8 +4719,6 @@ }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -5067,8 +4729,6 @@ }, "node_modules/jsonfile": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { @@ -5080,8 +4740,6 @@ }, "node_modules/jsonify": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "dev": true, "license": "Public Domain", "funding": { @@ -5106,8 +4764,6 @@ }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -5116,8 +4772,6 @@ }, "node_modules/klaw-sync": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", - "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5136,8 +4790,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5150,8 +4802,6 @@ }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "devOptional": true, "license": "MPL-2.0", "dependencies": { @@ -5285,6 +4935,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5305,6 +4958,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5325,6 +4981,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5345,6 +5004,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5380,8 +5042,6 @@ }, "node_modules/lightningcss-win32-x64-msvc": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -5418,8 +5078,6 @@ }, "node_modules/local-pkg": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "license": "MIT", "dependencies": { "mlly": "^1.7.4", @@ -5435,8 +5093,6 @@ }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -5451,22 +5107,16 @@ }, "node_modules/lodash": { "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { @@ -5577,8 +5227,6 @@ }, "node_modules/lru-cache": { "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5587,8 +5235,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -5596,8 +5242,6 @@ }, "node_modules/magic-string-ast": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", - "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", "license": "MIT", "dependencies": { "magic-string": "^0.30.19" @@ -5609,10 +5253,32 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/magicast": { + "version": "0.5.3", + "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", + "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", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5620,15 +5286,11 @@ }, "node_modules/mdn-data": { "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/memorystream": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", - "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", "dev": true, "engines": { "node": ">= 0.10.0" @@ -5636,15 +5298,11 @@ }, "node_modules/merge-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { @@ -5653,8 +5311,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -5690,8 +5346,6 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", "engines": { @@ -5713,8 +5367,6 @@ }, "node_modules/minimatch": { "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { @@ -5729,8 +5381,6 @@ }, "node_modules/minimist": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5738,8 +5388,6 @@ }, "node_modules/minipass": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5748,8 +5396,6 @@ }, "node_modules/mitt": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, "node_modules/mlly": { @@ -5766,14 +5412,10 @@ }, "node_modules/mlly/node_modules/confbox": { "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, "node_modules/mlly/node_modules/pkg-types": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "license": "MIT", "dependencies": { "confbox": "^0.1.8", @@ -5783,15 +5425,11 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/muggle-string": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", "license": "MIT" }, "node_modules/nanoid": { @@ -5814,15 +5452,11 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/node-fetch": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -5841,20 +5475,14 @@ }, "node_modules/node-fetch/node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -5863,8 +5491,6 @@ }, "node_modules/nopt": { "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "dev": true, "license": "ISC", "dependencies": { @@ -5879,8 +5505,6 @@ }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", - "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", "dev": true, "license": "ISC", "engines": { @@ -5889,8 +5513,6 @@ }, "node_modules/npm-run-all2": { "version": "8.0.4", - "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", - "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", "dev": true, "license": "MIT", "dependencies": { @@ -5916,8 +5538,6 @@ }, "node_modules/npm-run-all2/node_modules/ansi-styles": { "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -5929,8 +5549,6 @@ }, "node_modules/npm-run-all2/node_modules/isexe": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5939,8 +5557,6 @@ }, "node_modules/npm-run-all2/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -5952,8 +5568,6 @@ }, "node_modules/npm-run-all2/node_modules/which": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "license": "ISC", "dependencies": { @@ -5968,8 +5582,6 @@ }, "node_modules/npm-run-path": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, "license": "MIT", "dependencies": { @@ -5981,8 +5593,6 @@ }, "node_modules/nth-check": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5994,8 +5604,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6006,8 +5614,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -6016,8 +5622,6 @@ }, "node_modules/obug": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -6027,8 +5631,6 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -6037,8 +5639,6 @@ }, "node_modules/onetime": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", "dependencies": { @@ -6053,8 +5653,6 @@ }, "node_modules/open": { "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6070,8 +5668,6 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -6088,15 +5684,11 @@ }, "node_modules/ospath": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", - "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", "dev": true, "license": "MIT" }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6111,8 +5703,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -6127,8 +5717,6 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" }, @@ -6160,8 +5748,6 @@ }, "node_modules/patch-package": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", - "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", "dev": true, "license": "MIT", "dependencies": { @@ -6190,8 +5776,6 @@ }, "node_modules/patch-package/node_modules/ci-info": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, "funding": [ { @@ -6206,8 +5790,6 @@ }, "node_modules/patch-package/node_modules/fs-extra": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6221,15 +5803,11 @@ }, "node_modules/path-browserify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true, "license": "MIT" }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -6238,8 +5816,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -6248,8 +5824,6 @@ }, "node_modules/path-scurry": { "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -6265,15 +5839,11 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, "node_modules/pend": { @@ -6285,8 +5855,6 @@ }, "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "license": "MIT" }, "node_modules/performance-now": { @@ -6304,8 +5872,6 @@ }, "node_modules/picomatch": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6317,8 +5883,6 @@ }, "node_modules/pidtree": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", "dev": true, "license": "MIT", "bin": { @@ -6330,8 +5894,6 @@ }, "node_modules/pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, "license": "MIT", "engines": { @@ -6340,8 +5902,6 @@ }, "node_modules/pinia": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", - "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", "dependencies": { "@vue/devtools-api": "^7.7.7" @@ -6361,8 +5921,6 @@ }, "node_modules/pkg-types": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -6400,8 +5958,6 @@ }, "node_modules/postcss-selector-parser": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -6414,8 +5970,6 @@ }, "node_modules/powershell-utils": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", "dev": true, "license": "MIT", "engines": { @@ -6427,8 +5981,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -6437,8 +5989,6 @@ }, "node_modules/prettier": { "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -6453,8 +6003,6 @@ }, "node_modules/prettier-linter-helpers": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6466,8 +6014,6 @@ }, "node_modules/pretty-bytes": { "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "dev": true, "license": "MIT", "engines": { @@ -6479,8 +6025,6 @@ }, "node_modules/process": { "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, "license": "MIT", "engines": { @@ -6489,22 +6033,16 @@ }, "node_modules/proto-list": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true, "license": "ISC" }, "node_modules/proxy-from-env": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", - "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true, "license": "MIT" }, "node_modules/psl": { "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -6515,8 +6053,6 @@ }, "node_modules/pump": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, "license": "MIT", "dependencies": { @@ -6526,8 +6062,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { "node": ">=6" @@ -6550,8 +6084,6 @@ }, "node_modules/quansync": { "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "funding": [ { "type": "individual", @@ -6566,14 +6098,10 @@ }, "node_modules/querystringify": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, "node_modules/queue-microtask": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -6593,8 +6121,6 @@ }, "node_modules/read-package-json-fast": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", - "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", "dev": true, "license": "ISC", "dependencies": { @@ -6607,8 +6133,6 @@ }, "node_modules/readdirp": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -6620,8 +6144,6 @@ }, "node_modules/request-progress": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", "dev": true, "license": "MIT", "dependencies": { @@ -6630,8 +6152,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", "engines": { @@ -6640,8 +6160,6 @@ }, "node_modules/requires-port": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, "node_modules/restore-cursor": { @@ -6692,8 +6210,6 @@ }, "node_modules/reusify": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -6703,8 +6219,6 @@ }, "node_modules/rfdc": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, "node_modules/rolldown": { @@ -6743,8 +6257,6 @@ }, "node_modules/rollup-plugin-visualizer": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", - "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", "dev": true, "license": "MIT", "dependencies": { @@ -6774,8 +6286,6 @@ }, "node_modules/rollup-plugin-visualizer/node_modules/is-wsl": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -6790,8 +6300,6 @@ }, "node_modules/rollup-plugin-visualizer/node_modules/open": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -6811,8 +6319,6 @@ }, "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6824,8 +6330,6 @@ }, "node_modules/rollup-plugin-visualizer/node_modules/wsl-utils": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", "dev": true, "license": "MIT", "dependencies": { @@ -6841,8 +6345,6 @@ }, "node_modules/run-applescript": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, "license": "MIT", "engines": { @@ -6854,8 +6356,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -6878,8 +6378,6 @@ }, "node_modules/rxjs": { "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6916,8 +6414,6 @@ }, "node_modules/saxes": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -6929,14 +6425,10 @@ }, "node_modules/scule": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", - "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6948,14 +6440,10 @@ }, "node_modules/set-cookie-parser": { "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -6972,8 +6460,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -6985,8 +6471,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -7008,8 +6492,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7027,8 +6509,6 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7043,8 +6523,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7061,8 +6539,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7080,22 +6556,16 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, "node_modules/slash": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", - "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "dev": true, "license": "MIT", "engines": { @@ -7150,8 +6620,6 @@ }, "node_modules/source-map": { "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7160,8 +6628,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7169,8 +6635,6 @@ }, "node_modules/speakingurl": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7204,15 +6668,13 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/start-server-and-test": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-3.0.8.tgz", - "integrity": "sha512-BG1tHNyEW/mPhw50DFPb0uKoq7f7yNQFO+CJb83MKZkCPKmWqb522YGMM3f4XG1Kra2v3xU3ou6O+s8taChM6A==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-3.0.11.tgz", + "integrity": "sha512-NRapOwJl6jr1DNSaQ+SRukHI2OKcFZA2Iv2tfTW9fI/S+6YmJGiwacR+0MG3o5p39lY4xWUOE5JFkKJBZUjxuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7235,8 +6697,6 @@ }, "node_modules/start-server-and-test/node_modules/execa": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", "dependencies": { @@ -7259,8 +6719,6 @@ }, "node_modules/start-server-and-test/node_modules/get-stream": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", "engines": { @@ -7272,8 +6730,6 @@ }, "node_modules/start-server-and-test/node_modules/human-signals": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7282,15 +6738,11 @@ }, "node_modules/std-env": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, "node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -7305,8 +6757,6 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -7320,8 +6770,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -7334,8 +6782,6 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -7347,8 +6793,6 @@ }, "node_modules/strip-final-newline": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, "license": "MIT", "engines": { @@ -7357,8 +6801,6 @@ }, "node_modules/superjson": { "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", "license": "MIT", "dependencies": { "copy-anything": "^4" @@ -7369,8 +6811,6 @@ }, "node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7385,15 +6825,11 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/synckit": { "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7407,9 +6843,7 @@ } }, "node_modules/systeminformation": { - "version": "5.31.7", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", - "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", + "version": "5.31.1", "dev": true, "license": "MIT", "os": [ @@ -7435,8 +6869,6 @@ }, "node_modules/throttleit": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", - "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", "dev": true, "license": "MIT", "funding": { @@ -7445,15 +6877,11 @@ }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, "license": "MIT", "engines": { @@ -7478,8 +6906,6 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7495,8 +6921,6 @@ }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7507,8 +6931,6 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -7536,9 +6958,7 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "version": "0.2.5", "dev": true, "license": "MIT", "engines": { @@ -7547,8 +6967,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7573,8 +6991,6 @@ }, "node_modules/tr46": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { @@ -7586,8 +7002,6 @@ }, "node_modules/tree-kill": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, "license": "MIT", "bin": { @@ -7596,8 +7010,6 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -7609,8 +7021,6 @@ }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "devOptional": true, "license": "0BSD" }, @@ -7636,8 +7046,6 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -7649,8 +7057,6 @@ }, "node_modules/type-fest": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -7659,8 +7065,6 @@ }, "node_modules/typescript": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -7672,16 +7076,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", - "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1" + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7697,8 +7101,6 @@ }, "node_modules/ufo": { "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/undici": { @@ -7712,16 +7114,14 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { @@ -7730,8 +7130,6 @@ }, "node_modules/unplugin": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", - "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -7744,8 +7142,6 @@ }, "node_modules/unplugin-utils": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", - "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", "license": "MIT", "dependencies": { "pathe": "^2.0.3", @@ -7760,8 +7156,6 @@ }, "node_modules/unplugin-utils/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7772,8 +7166,6 @@ }, "node_modules/unplugin/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7784,8 +7176,6 @@ }, "node_modules/untildify": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", "dev": true, "license": "MIT", "engines": { @@ -7794,8 +7184,6 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7804,8 +7192,6 @@ }, "node_modules/url-parse": { "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "license": "MIT", "dependencies": { "querystringify": "^2.1.1", @@ -7814,8 +7200,6 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, "license": "MIT" }, @@ -7914,8 +7298,6 @@ }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { @@ -7926,19 +7308,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7966,12 +7348,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.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8017,8 +7399,6 @@ }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8030,22 +7410,20 @@ }, "node_modules/vscode-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "dev": true, "license": "MIT" }, "node_modules/vue": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", - "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.35", - "@vue/compiler-sfc": "3.5.35", - "@vue/runtime-dom": "3.5.35", - "@vue/server-renderer": "3.5.35", - "@vue/shared": "3.5.35" + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" @@ -8058,15 +7436,11 @@ }, "node_modules/vue-component-type-helpers": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", - "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", "dev": true, "license": "MIT" }, "node_modules/vue-eslint-parser": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", - "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", "dependencies": { @@ -8089,8 +7463,6 @@ }, "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8150,30 +7522,30 @@ } }, "node_modules/vue-router/node_modules/@vue/devtools-api": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz", - "integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.3.tgz", + "integrity": "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==", "license": "MIT", "dependencies": { - "@vue/devtools-kit": "^8.1.2" + "@vue/devtools-kit": "^8.1.3" } }, "node_modules/vue-router/node_modules/@vue/devtools-kit": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz", - "integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.3.tgz", + "integrity": "sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA==", "license": "MIT", "dependencies": { - "@vue/devtools-shared": "^8.1.2", + "@vue/devtools-shared": "^8.1.3", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "node_modules/vue-router/node_modules/@vue/devtools-shared": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz", - "integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.3.tgz", + "integrity": "sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==", "license": "MIT" }, "node_modules/vue-router/node_modules/perfect-debounce": { @@ -8184,8 +7556,6 @@ }, "node_modules/vue-router/node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -8195,14 +7565,14 @@ } }, "node_modules/vue-tsc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.3.tgz", - "integrity": "sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.5.tgz", + "integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==", "dev": true, "license": "MIT", "dependencies": { "@volar/typescript": "2.4.28", - "@vue/language-core": "3.3.3" + "@vue/language-core": "3.3.5" }, "bin": { "vue-tsc": "bin/vue-tsc.js" @@ -8213,8 +7583,6 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -8226,8 +7594,6 @@ }, "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8262,8 +7628,6 @@ }, "node_modules/webidl-conversions": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8272,14 +7636,10 @@ }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, "node_modules/whatwg-mimetype": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { @@ -8288,8 +7648,6 @@ }, "node_modules/whatwg-url": { "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", "dependencies": { @@ -8303,8 +7661,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -8319,8 +7675,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -8336,8 +7690,6 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -8365,8 +7717,6 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8450,15 +7800,11 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/ws": { "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -8478,8 +7824,6 @@ }, "node_modules/xml-name-validator": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8488,15 +7832,11 @@ }, "node_modules/xmlchars": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", "engines": { @@ -8601,9 +7941,9 @@ } }, "node_modules/yauzl": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.2.tgz", - "integrity": "sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, "license": "MIT", "dependencies": { @@ -8615,8 +7955,6 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { diff --git a/fe/package.json b/fe/package.json index 1f5b25f44..d1ddf42ad 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,18 +20,26 @@ "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", "build:analyze": "vite build --mode analysis", - "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:prettier": "prettier --write src/", @@ -55,6 +64,7 @@ "@types/jsdom": "^28.0.3", "@types/node": "^24.13.1", "@vitejs/plugin-vue": "^6.0.7", + "@vitest/coverage-v8": "^4.1.8", "@vitest/eslint-plugin": "^1.6.19", "@vue/compiler-sfc": "^3.5.35", "@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/App.vue b/fe/src/App.vue index 1571c998b..4c54c1df3 100644 --- a/fe/src/App.vue +++ b/fe/src/App.vue @@ -574,6 +574,7 @@ import GlobalToast from '@/components/ui/GlobalToast.vue' import { useToast } from '@/services/toastService' import { logger } from '@/utils/logger' import BrandLogo from '@/components/base/BrandLogo.vue' +import { parseAuthRequiredFromConfig } from '@/utils/authConfig' import { SECURITY_WARNING_BANNER_PREF_EVENT, SECURITY_WARNING_BANNER_PREF_KEY, @@ -1051,28 +1052,6 @@ watch( }, ) -const parseAuthEnabledFromStartupConfig = (raw: unknown): boolean | null => { - if (typeof raw === 'boolean') return raw - if (typeof raw === 'string') { - const normalized = raw.toLowerCase().trim() - if ( - normalized === 'enabled' || - normalized === 'true' || - normalized === 'yes' || - normalized === '1' - ) - return true - if ( - normalized === 'disabled' || - normalized === 'false' || - normalized === 'no' || - normalized === '0' - ) - return false - } - return null -} - const refreshAuthPresentationFromStartupConfig = async (force: boolean = false) => { try { // Use cached startup-config helper so unauthenticated 401 is interpreted as @@ -1087,9 +1066,7 @@ const refreshAuthPresentationFromStartupConfig = async (force: boolean = false) throw err } } - const obj = cfg as Record | null - const raw = obj ? (obj['authenticationRequired'] ?? obj['AuthenticationRequired']) : undefined - const parsedAuthEnabled = parseAuthEnabledFromStartupConfig(raw) + const parsedAuthEnabled = parseAuthRequiredFromConfig(cfg) // Only show the "auth disabled" banner when startup config explicitly says auth is off. // Unknown/missing/transient states should not be treated as disabled. authEnabled.value = parsedAuthEnabled ?? true @@ -1102,10 +1079,25 @@ const refreshAuthPresentationFromStartupConfig = async (force: boolean = false) } } +const redirectToLoginIfCurrentRouteRequiresAuth = async () => { + if (!startupConfigLoaded.value || !authEnabled.value || auth.user.authenticated) { + return + } + + if (route.name === 'login' || !(route.meta as Record)?.requiresAuth) { + return + } + + const redirect = route.fullPath || '/' + logger.debug('[App] Auth state changed on protected route, redirecting to login', { redirect }) + await router.replace({ name: 'login', query: { redirect } }) +} + watch( () => auth.user.authenticated, - () => { - void refreshAuthPresentationFromStartupConfig(true) + async () => { + await refreshAuthPresentationFromStartupConfig(true) + await redirectToLoginIfCurrentRouteRequiresAuth() }, ) @@ -1350,13 +1342,11 @@ const logout = async () => { try { logger.debug('Logout button clicked') await auth.logout() - logger.debug('Auth logout completed, redirecting to login') - // Instead of reloading, redirect to login - the router guard will handle authentication - await router.push({ name: 'login' }) + logger.debug('Auth logout completed') + await redirectToLoginIfCurrentRouteRequiresAuth() } catch (error) { logger.error('Error during logout:', error) - // Force redirect to login even if logout fails - await router.push({ name: 'login' }) + await redirectToLoginIfCurrentRouteRequiresAuth() } } diff --git a/fe/src/__tests__/auth.store.spec.ts b/fe/src/__tests__/auth.store.spec.ts deleted file mode 100644 index 53f6c4c3c..000000000 --- a/fe/src/__tests__/auth.store.spec.ts +++ /dev/null @@ -1,146 +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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createPinia, setActivePinia } from 'pinia' -import { sessionTokenManager } from '@/utils/sessionToken' - -const getCurrentUserMock = vi.fn() -const loginMock = vi.fn() -const logoutMock = vi.fn() -const getStartupConfigCachedMock = vi.fn() - -vi.mock('@/services/api', () => ({ - apiService: { - getCurrentUser: (...args: unknown[]) => getCurrentUserMock(...args), - login: (...args: unknown[]) => loginMock(...args), - logout: (...args: unknown[]) => logoutMock(...args), - }, -})) - -vi.mock('@/utils/sessionDebug', () => ({ - clearAllAuthData: vi.fn(), -})) - -vi.mock('@/services/errorTracking', () => ({ - errorTracking: { - captureException: vi.fn(), - }, -})) - -vi.mock('@/services/startupConfigCache', () => ({ - getStartupConfigCached: (...args: unknown[]) => getStartupConfigCachedMock(...args), -})) - -import { useAuthStore } from '@/stores/auth' -import { createAppRouter } from '@/router' - -const router = createAppRouter() - -describe('auth store cross-tab sync', () => { - beforeEach(async () => { - setActivePinia(createPinia()) - getCurrentUserMock.mockReset() - getCurrentUserMock.mockResolvedValue({ authenticated: false }) - loginMock.mockReset() - logoutMock.mockReset() - getStartupConfigCachedMock.mockReset() - getStartupConfigCachedMock.mockResolvedValue({ authenticationRequired: true }) - sessionTokenManager.clearToken() - localStorage.removeItem('listenarr_session_token') - localStorage.removeItem('listenarr_session_event') - localStorage.removeItem('listenarr_session_token_persistence') - sessionStorage.removeItem('listenarr_session_token') - window.history.replaceState({}, '', '/login') - await router.replace('/login') - getCurrentUserMock.mockClear() - }) - - afterEach(async () => { - sessionTokenManager.clearToken() - localStorage.removeItem('listenarr_session_token') - localStorage.removeItem('listenarr_session_event') - localStorage.removeItem('listenarr_session_token_persistence') - sessionStorage.removeItem('listenarr_session_token') - window.history.replaceState({}, '', '/') - await router.replace('/') - }) - - it('loads the current user when another tab broadcasts a login event', async () => { - getCurrentUserMock.mockResolvedValue({ authenticated: true, name: 'cross-tab-user' }) - - const store = useAuthStore() - - const loginEvent = new Event('storage') - Object.defineProperty(loginEvent, 'key', { value: 'listenarr_session_event' }) - Object.defineProperty(loginEvent, 'newValue', { - value: JSON.stringify({ authenticated: true, at: Date.now() }), - }) - - window.dispatchEvent(loginEvent) - - await vi.waitFor(() => { - expect(getCurrentUserMock).toHaveBeenCalledTimes(1) - expect(store.user.authenticated).toBe(true) - expect(store.user.name).toBe('cross-tab-user') - }) - }) - - it('does not redirect on initial empty auth marker state', async () => { - setActivePinia(createPinia()) - const store = useAuthStore() - - await Promise.resolve() - - expect(router.currentRoute.value.name).toBe('login') - expect(store.loaded).toBe(false) - expect(getCurrentUserMock).not.toHaveBeenCalled() - }) - - it('redirects to login when another tab broadcasts a logout event while auth is required', async () => { - const store = useAuthStore() - store.user = { authenticated: true, name: 'cross-tab-user' } - store.loaded = true // prevent router guard from re-calling loadCurrentUser() and resetting auth state - await router.replace('/settings') - - const logoutEvent = new Event('storage') - Object.defineProperty(logoutEvent, 'key', { value: 'listenarr_session_event' }) - Object.defineProperty(logoutEvent, 'newValue', { - value: JSON.stringify({ authenticated: false, at: Date.now() }), - }) - - window.dispatchEvent(logoutEvent) - - await vi.waitFor(() => { - expect(router.currentRoute.value.name).toBe('login') - expect(router.currentRoute.value.query.redirect).toBe('/settings') - expect(store.user.authenticated).toBe(false) - }) - }) - - it('clears a stale browser auth marker when /account/me reports unauthenticated', async () => { - getCurrentUserMock.mockResolvedValue({ authenticated: false }) - - const store = useAuthStore() - sessionTokenManager.setAuthenticated() - - await vi.waitFor(() => { - expect(store.user.authenticated).toBe(false) - expect(sessionTokenManager.getToken()).toBeNull() - }) - }) -}) 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 06d5b7b38..000000000 --- a/fe/src/__tests__/test-setup.ts +++ /dev/null @@ -1,460 +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 || ''), - getBootstrapConfig: vi.fn(async () => ({})), - 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 - getBootstrapConfig: vi.fn(async () => ({})), - 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__/ProgressBar.spec.ts b/fe/src/components/base/test/ProgressBar.spec.ts similarity index 100% rename from fe/src/__tests__/ProgressBar.spec.ts rename to fe/src/components/base/test/ProgressBar.spec.ts 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 90% rename from fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts rename to fe/src/components/domain/audiobook/test/EditAudiobookModal.moveOptions.spec.ts index 30f882c7b..5a4ef5119 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( @@ -335,10 +336,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') @@ -388,10 +389,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: { @@ -401,7 +402,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 92% rename from fe/src/__tests__/LibraryImportSearchModal.spec.ts rename to fe/src/components/domain/audiobook/test/LibraryImportSearchModal.spec.ts index 0207d6f9c..f56f55122 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__/StorageDisksList.spec.ts b/fe/src/components/system/test/StorageDisksList.spec.ts similarity index 91% rename from fe/src/__tests__/StorageDisksList.spec.ts rename to fe/src/components/system/test/StorageDisksList.spec.ts index eb4267b8c..9f6a1dd71 100644 --- a/fe/src/__tests__/StorageDisksList.spec.ts +++ b/fe/src/components/system/test/StorageDisksList.spec.ts @@ -52,7 +52,6 @@ describe('StorageDisksList', () => { expect(entries).toHaveLength(2) expect(entries[0].text()).toContain('App Data') expect(entries[0].text()).toContain('/app/config') - // #508 is about available space — show free of total per row, not used/total expect(entries[0].text()).toContain('0.9 GB free of 4.06 GB') expect(entries[1].text()).toContain('Audiobooks') expect(entries[1].text()).toContain('/audiobooks') @@ -66,14 +65,12 @@ describe('StorageDisksList', () => { expect(bars).toHaveLength(1) expect(bars[0].props('value')).toBe(15) expect(bars[0].props('showPercentage')).toBe(true) - // free-of-total replaces the bar's own used/total readout expect(bars[0].props('showSize')).toBeFalsy() expect(bars[0].props('downloaded')).toBeUndefined() expect(bars[0].props('total')).toBeUndefined() }) - it('renders distinct entries when two disks share a path (no duplicate keys)', () => { - // e.g. a user configures "/" as a root folder alongside the System "/" entry + it('renders distinct entries when two disks share a path', () => { const disks = [makeDisk({ label: 'System', path: '/' }), makeDisk({ label: 'Root', path: '/' })] const wrapper = mount(StorageDisksList, { props: { disks } }) 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/router/index.ts b/fe/src/router/index.ts index 914f8772f..4ff6fe057 100644 --- a/fe/src/router/index.ts +++ b/fe/src/router/index.ts @@ -20,10 +20,8 @@ import { useAuthStore } from '@/stores/auth' import { getStartupConfigCached } from '@/services/startupConfigCache' import { logger } from '@/utils/logger' import type { StartupConfig } from '@/types' - -// Module-level cache/promise for startup config to avoid repeated requests during rapid navigation -// Use a promise so concurrent navigations share the same inflight request instead of issuing many -// Module-level cache moved to services/startupConfigCache +import { parseAuthRequiredFromConfig } from '@/utils/authConfig' +import { normalizeRedirect, redirectLocationFromPath } from '@/utils/redirect' const routes = [ { @@ -122,10 +120,7 @@ const redactStartupConfigForLog = ( return cloned } -// Preload helper: given a route name or path, trigger the route's lazy component import -// without navigating. Returns the import promise or a resolved promise when not found. export function preloadRoute(nameOrPath: string) { - // try by name first const byName = routes.find((r) => r.name === nameOrPath) if (byName && typeof byName.component === 'function') { try { @@ -134,7 +129,7 @@ export function preloadRoute(nameOrPath: string) { return Promise.resolve() } } - // try by path + const byPath = routes.find( (r) => r.path === nameOrPath || @@ -147,26 +142,19 @@ export function preloadRoute(nameOrPath: string) { return Promise.resolve() } } + return Promise.resolve() } -/** - * Module-level reference set by createAppRouter(). - * Used by code that lazily imports the router (e.g. auth store). - */ -let _routerInstance: ReturnType | null = null +const getQueryString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined -// Factory function to create and configure the router. -// Deferred to avoid calling createWebHistory/createRouter at module top-level, -// which triggers a Rolldown (Vite 8) circular-dependency crash where vue-router -// symbols are not yet initialized when this module is first evaluated. export function createAppRouter() { const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, }) - // Navigation guard: protect routes requiring auth and preserve redirectTo router.beforeEach(async (to, from) => { if (import.meta.env.CYPRESS) return true const auth = useAuthStore() @@ -182,168 +170,58 @@ export function createAppRouter() { loaded: auth.loaded, }) - // Load current user only once per app lifetime if (!auth.loaded) { try { await auth.loadCurrentUser() } catch {} } - // Fetch startup config with a short cache window so rapid navigations do not each - // block on a network round-trip. Auth settings rarely change mid-session; 30 s is - // conservative enough to stay in sync while avoiding per-navigation latency. const startupConfig = await getStartupConfigCached(30_000) const startupConfigMissing = !startupConfig logger.debug('[router] startupConfigMissing', startupConfigMissing) logger.debug('[router] startupConfig', redactStartupConfigForLog(startupConfig)) - const authRequiredConfig = (() => { - if (startupConfigMissing) { - logger.debug('[router] startupConfig missing, defaulting authRequiredConfig to false') - // If the backend is temporarily unreachable or the config fetch fails, - // do not force the login screen. Treat missing config as "no auth" - // to avoid blocking the SPA from loading. - return false - } - const raw = - startupConfig?.authenticationRequired ?? - (startupConfig as StartupConfig & { AuthenticationRequired?: string | boolean }) - ?.AuthenticationRequired - logger.debug('[router] startupConfig raw authRequired:', raw) - const v = raw - if (v === undefined || v === null) { - logger.debug('[router] authRequiredConfig: value undefined/null, returning false') - return false - } - if (typeof v === 'boolean') { - logger.debug('[router] authRequiredConfig: boolean value', v) - return v - } - if (typeof v === 'string') { - const parsed = v.toLowerCase() === 'enabled' || v.toLowerCase() === 'true' - logger.debug('[router] authRequiredConfig: string value', v, 'parsed as', parsed) - return parsed - } - logger.debug('[router] authRequiredConfig: unknown type, returning false') - return false - })() + const authRequiredConfig = startupConfigMissing + ? false + : (parseAuthRequiredFromConfig(startupConfig) ?? false) logger.debug('[router] FINAL authRequiredConfig:', authRequiredConfig) - // If authentication is disabled in startup config, prevent access to login page if (!authRequiredConfig) { - // Authentication globally disabled: don't enforce requiresAuth. - // Still prevent navigating to the login page when auth is disabled. if (to.name === 'login') { - // Allow explicitly forced login navigation (used right after enabling auth) - // to avoid race conditions where startup config propagation briefly reports - // authentication as disabled. if (forceLogin && !auth.user.authenticated) { logger.debug('[router] force login requested; allowing login route despite auth config') return true } - // Check if there's a redirect parameter - if so, honor it instead of going to home - // Also check auth.redirectTo store as fallback (set during initial navigation attempts) - const redirectPath = (to.query.redirect as string | undefined) || auth.redirectTo - if (redirectPath) { - // Parse and navigate to the intended destination - try { - const url = new URL(redirectPath, window.location.origin) - const dest = { - path: url.pathname, - query: Object.fromEntries(url.searchParams), - hash: url.hash, // Preserve the hash/anchor (e.g., #indexers) - } - auth.redirectTo = null - logger.debug( - '[router] auth disabled, but redirect found in store/query, going to:', - dest, - ) - return dest - } catch { - // Fallback to string path - auth.redirectTo = null - logger.debug( - '[router] auth disabled, but redirect found in store/query (fallback), going to:', - redirectPath, - ) - return redirectPath - } + const redirectPath = normalizeRedirect(getQueryString(to.query.redirect)) + if (redirectPath !== '/') { + const dest = redirectLocationFromPath(redirectPath) + logger.debug('[router] auth disabled, but redirect found in query, going to:', dest) + return dest } - // No redirect - go to home logger.debug('[router] auth disabled, no redirect found, going to home') return { name: 'home' } } - - // Auth disabled: allow all other routes through without checking authentication - // But still preserve the intended destination if user tried to access login somehow - if (!auth.loaded && to.meta.requiresAuth) { - // First time loading a protected route - save it before auth finishes loading - auth.redirectTo = to.fullPath - logger.debug('[router] auth disabled, saving redirect for initial load:', to.fullPath) - } - } else { - // Authentication enabled: enforce protection on routes marked as requiresAuth - if ((to.meta as Record)?.requiresAuth && !auth.user.authenticated) { - // Preserve the intended route and redirect to login - // Use a query param so the redirect survives page reloads; also keep store as fallback - auth.redirectTo = to.fullPath - logger.debug('[router] requiresAuth and not authenticated, redirecting to login', { - redirect: to.fullPath, - }) - return { name: 'login', query: { redirect: to.fullPath } } - } + } else if ((to.meta as Record)?.requiresAuth && !auth.user.authenticated) { + logger.debug('[router] requiresAuth and not authenticated, redirecting to login', { + redirect: to.fullPath, + }) + return { name: 'login', query: { redirect: to.fullPath } } } - // If already authenticated and going to login, redirect to saved destination if (to.name === 'login' && auth.user.authenticated) { - // Check for redirect in query params first (survives page reloads), then fall back to store - const redirectPath = (to.query.redirect as string | undefined) || auth.redirectTo - - if (redirectPath) { - // Parse the redirect path to extract path, query, and hash components - // This ensures we properly handle anchors like /settings#indexers - try { - const url = new URL(redirectPath, window.location.origin) - const dest = { - path: url.pathname, - query: Object.fromEntries(url.searchParams), - hash: url.hash, // Preserve the hash/anchor (e.g., #indexers) - } - auth.redirectTo = null - logger.debug('[router] authenticated user on login page, redirecting to:', dest) - return dest - } catch { - // Fallback: if URL parsing fails, use the path string directly - // Vue Router should still handle it correctly - auth.redirectTo = null - logger.debug( - '[router] authenticated user on login page, redirecting to (fallback):', - redirectPath, - ) - return redirectPath - } + const redirectPath = normalizeRedirect(getQueryString(to.query.redirect)) + if (redirectPath !== '/') { + const dest = redirectLocationFromPath(redirectPath) + logger.debug('[router] authenticated user on login page, redirecting to:', dest) + return dest } - // No redirect path - go to home - auth.redirectTo = null return { name: 'home' } } return true }) - _routerInstance = router return router } - -/** - * Returns the router instance previously created by createAppRouter(). - * Throws if called before createAppRouter(). - */ -export function getRouter() { - if (!_routerInstance) { - throw new Error('Router not initialized – call createAppRouter() first') - } - return _routerInstance -} diff --git a/fe/src/router/test/auth-guards.spec.ts b/fe/src/router/test/auth-guards.spec.ts new file mode 100644 index 000000000..7ad827011 --- /dev/null +++ b/fe/src/router/test/auth-guards.spec.ts @@ -0,0 +1,124 @@ +/* + * 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 { beforeEach, describe, expect, it, vi } from 'vitest' + +const auth = vi.hoisted(() => ({ + user: { authenticated: false }, + loaded: true, + loadCurrentUser: vi.fn(async () => undefined), +})) + +const getStartupConfigCached = vi.hoisted(() => vi.fn()) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => auth, +})) + +vi.mock('@/services/startupConfigCache', () => ({ + getStartupConfigCached, +})) + +vi.mock('@/utils/logger', () => ({ + logger: { + log: vi.fn(), + debug: vi.fn(), + }, +})) + +async function loadRouter() { + vi.resetModules() + const { createAppRouter } = await import('@/router') + return createAppRouter() +} + +describe('router auth guards', () => { + beforeEach(() => { + auth.user.authenticated = false + auth.loaded = true + auth.loadCurrentUser.mockClear() + getStartupConfigCached.mockReset() + getStartupConfigCached.mockResolvedValue({ authenticationRequired: true }) + window.history.replaceState({}, '', '/') + }) + + it('redirects unauthenticated protected routes to login with the target preserved', async () => { + const router = await loadRouter() + + await router.push('/settings') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/settings') + }) + + it('allows protected routes when authentication is disabled', async () => { + getStartupConfigCached.mockResolvedValue({ authenticationRequired: false }) + const router = await loadRouter() + + await router.push('/settings') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('settings') + }) + + it('redirects login to home when auth is disabled unless force login is requested', async () => { + getStartupConfigCached.mockResolvedValue({ authenticationRequired: false }) + let router = await loadRouter() + + await router.push('/login') + await router.isReady() + expect(router.currentRoute.value.name).toBe('home') + + router = await loadRouter() + await router.push('/login?force=1') + await router.isReady() + expect(router.currentRoute.value.name).toBe('login') + }) + + it('redirects authenticated login visits to a safe redirect target', async () => { + auth.user.authenticated = true + const router = await loadRouter() + + await router.push('/login?redirect=/settings%3Ftab%3Dgeneral%23indexers') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('settings') + expect(router.currentRoute.value.query.tab).toBe('general') + expect(router.currentRoute.value.hash).toBe('#indexers') + }) + + it('falls back home for unsafe login redirect values', async () => { + auth.user.authenticated = true + const router = await loadRouter() + + await router.push('/login?redirect=https%3A%2F%2Fevil.example%2F') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('home') + }) + + it('treats missing startup config as auth disabled for routing fallback', async () => { + getStartupConfigCached.mockResolvedValue(null) + const router = await loadRouter() + + await router.push('/settings') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('settings') + }) +}) 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 93% rename from fe/src/__tests__/api.downloadLogs.spec.ts rename to fe/src/services/test/api.downloadLogs.spec.ts index e1ed11f9f..0df5ea6ce 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,10 @@ describe('ApiService.downloadLogs', () => { it('downloads logs through cookie-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') const originalCreateElement = document.createElement.bind(document) const anchor = originalCreateElement('a') diff --git a/fe/src/__tests__/api.ensureImageCached.spec.ts b/fe/src/services/test/api.ensureImageCached.node.spec.ts similarity index 93% rename from fe/src/__tests__/api.ensureImageCached.spec.ts rename to fe/src/services/test/api.ensureImageCached.node.spec.ts index 3dbf59ee2..1bc386f5a 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[] } } @@ -53,7 +53,7 @@ describe('ApiService.ensureImageCached', () => { ) 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, ) @@ -74,7 +74,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) }) diff --git a/fe/src/__tests__/api.imageUrls.spec.ts b/fe/src/services/test/api.imageUrls.node.spec.ts similarity index 93% rename from fe/src/__tests__/api.imageUrls.spec.ts rename to fe/src/services/test/api.imageUrls.node.spec.ts index 402202e1f..75a248851 100644 --- a/fe/src/__tests__/api.imageUrls.spec.ts +++ b/fe/src/services/test/api.imageUrls.node.spec.ts @@ -17,9 +17,9 @@ */ import { describe, expect, it, vi } from 'vitest' -vi.unmock('../services/api') +vi.unmock('@/services/api') -import { apiService as svc } from '../services/api' +import { apiService as svc } from '@/services/api' import { API_BASE_PATH } from '@/services/apiBase' describe('ApiService image URLs', () => { 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 81% rename from fe/src/__tests__/startupConfigCache.test.ts rename to fe/src/services/test/startupConfigCache.node.spec.ts index 2a5b00284..b993992d4 100644 --- a/fe/src/__tests__/startupConfigCache.test.ts +++ b/fe/src/services/test/startupConfigCache.node.spec.ts @@ -15,9 +15,10 @@ * 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 } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import * as cache from '@/services/startupConfigCache' import { apiService } from '@/services/api' +import { createDeferred } from '@/test/utils/wait' // Mock apiService.getBootstrapConfig with a delayed resolver let originalGet: unknown @@ -27,15 +28,16 @@ beforeEach(() => { originalGet = (apiService as unknown as { getBootstrapConfig?: unknown }).getBootstrapConfig }) +afterEach(() => { + ;(apiService as unknown as { getBootstrapConfig?: unknown }).getBootstrapConfig = originalGet +}) + describe('startupConfigCache', () => { it('deduplicates concurrent calls', async () => { - let resolve: (value: unknown) => void - const p = new Promise((res) => { - resolve = res - }) + const pendingConfig = createDeferred() ;(apiService as unknown as { getBootstrapConfig?: () => Promise }).getBootstrapConfig = () => { - return p + return pendingConfig.promise } // Start multiple concurrent callers @@ -45,8 +47,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) @@ -54,9 +55,3 @@ describe('startupConfigCache', () => { expect(cache.fetchCount).toBe(1) }) }) - -// restore -const restore = originalGet as unknown -if (restore) { - ;(apiService as unknown as { getBootstrapConfig?: unknown }).getBootstrapConfig = restore -} diff --git a/fe/src/stores/auth.ts b/fe/src/stores/auth.ts index e2ab3af5d..d04ea8f0e 100644 --- a/fe/src/stores/auth.ts +++ b/fe/src/stores/auth.ts @@ -21,70 +21,13 @@ import { apiService } from '@/services/api' import { sessionTokenManager } from '@/utils/sessionToken' import { clearAllAuthData } from '@/utils/sessionDebug' import { errorTracking } from '@/services/errorTracking' -import { getStartupConfigCached } from '@/services/startupConfigCache' export const useAuthStore = defineStore('auth', () => { const user = ref<{ authenticated: boolean; name?: string }>({ authenticated: false }) // Whether we've attempted to load the current user at least once const loaded = ref(false) - const redirectTo = ref(null) let currentUserLoadPromise: Promise | null = null - const isAuthRequired = async (): Promise => { - try { - const cfg = await getStartupConfigCached(0) - const raw = - cfg?.authenticationRequired ?? - (cfg as { AuthenticationRequired?: string | boolean } | null)?.AuthenticationRequired - - if (typeof raw === 'boolean') { - return raw - } - - if (typeof raw === 'string') { - const normalized = raw.toLowerCase().trim() - return ( - normalized === 'enabled' || - normalized === 'true' || - normalized === 'yes' || - normalized === '1' - ) - } - } catch {} - - return false - } - - const redirectToLoginIfRequired = async () => { - if (!(await isAuthRequired())) { - return - } - - const current = window.location.pathname + window.location.search + window.location.hash - if (current.startsWith('/login')) { - return - } - - try { - const routerModule = await import('@/router') - const router = routerModule.getRouter() - const route = router.currentRoute.value - const redirect = route.fullPath || current - - if (route.name === 'login') { - return - } - - await router.replace({ name: 'login', query: { redirect } }) - } catch { - try { - window.location.href = `/login?redirect=${encodeURIComponent(current)}` - } catch { - window.location.href = '/login' - } - } - } - const loadCurrentUser = async () => { if (currentUserLoadPromise) { return currentUserLoadPromise @@ -112,7 +55,6 @@ export const useAuthStore = defineStore('auth', () => { : 0 if (status === 401 || status === 403) { console.log('[AuthStore] Authentication error - clearing session') - // Clear any stale cross-tab auth marker when we get auth errors. try { if (sessionTokenManager.hasToken()) { sessionTokenManager.clearToken() @@ -142,6 +84,10 @@ export const useAuthStore = defineStore('auth', () => { // React to browser auth marker changes from other tabs (cross-tab login/logout). try { sessionTokenManager.onTokenChange((token, context) => { + if (context?.source === 'initial') { + return + } + if (token) { if (!user.value.authenticated) { void loadCurrentUser() @@ -149,20 +95,30 @@ export const useAuthStore = defineStore('auth', () => { return } - if (!token) { - if (context?.source === 'initial') { - return - } - - console.log('[AuthStore] Auth marker removed in another tab - clearing auth state') - user.value = { authenticated: false } - loaded.value = true - - void redirectToLoginIfRequired() - } + console.log('[AuthStore] Auth marker removed in another tab - clearing auth state') + user.value = { authenticated: false } + loaded.value = true }) } catch {} + const clearClientAuthState = () => { + try { + sessionTokenManager.clearToken() + } catch (e) { + console.warn('[AuthStore] Failed to clear sessionTokenManager:', e) + } + + try { + // Comprehensive cleanup (removes any lingering storage keys) + clearAllAuthData() + } catch (e) { + console.warn('[AuthStore] Failed to run clearAllAuthData:', e) + } + + user.value = { authenticated: false } + loaded.value = true + } + const logout = async () => { try { console.log('[AuthStore] Starting logout...') @@ -176,23 +132,10 @@ export const useAuthStore = defineStore('auth', () => { // Continue with local logout even if API call fails } finally { // Ensure all client-side auth data is cleared even if the API call failed - try { - sessionTokenManager.clearToken() - } catch (e) { - console.warn('[AuthStore] Failed to clear sessionTokenManager:', e) - } - - try { - // Comprehensive cleanup (removes any lingering storage keys) - clearAllAuthData() - } catch (e) { - console.warn('[AuthStore] Failed to run clearAllAuthData:', e) - } - - user.value = { authenticated: false } + clearClientAuthState() console.log('[AuthStore] Local user state cleared and auth data removed') } } - return { user, redirectTo, loadCurrentUser, login, logout, loaded } + return { user, loadCurrentUser, login, logout, loaded } }) 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/stores/test/auth.store.spec.ts b/fe/src/stores/test/auth.store.spec.ts new file mode 100644 index 000000000..7e782dbc6 --- /dev/null +++ b/fe/src/stores/test/auth.store.spec.ts @@ -0,0 +1,140 @@ +/* + * 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 { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const apiMocks = vi.hoisted(() => ({ + getCurrentUser: vi.fn(), + login: vi.fn(), + logout: vi.fn(), +})) + +const clearAllAuthData = vi.hoisted(() => vi.fn()) +const captureException = vi.hoisted(() => vi.fn()) + +vi.mock('@/services/api', () => ({ + apiService: { + getCurrentUser: apiMocks.getCurrentUser, + login: apiMocks.login, + logout: apiMocks.logout, + }, +})) + +vi.mock('@/utils/sessionDebug', () => ({ + clearAllAuthData, +})) + +vi.mock('@/services/errorTracking', () => ({ + errorTracking: { + captureException, + }, +})) + +const resetStorage = () => { + localStorage.removeItem('listenarr_session_token') + localStorage.removeItem('listenarr_session_token_persistence') + sessionStorage.removeItem('listenarr_session_token') +} + +describe('auth store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + apiMocks.getCurrentUser.mockReset() + apiMocks.getCurrentUser.mockResolvedValue({ authenticated: false }) + apiMocks.login.mockReset() + apiMocks.logout.mockReset() + clearAllAuthData.mockReset() + captureException.mockReset() + resetStorage() + }) + + it('loads the current user when another tab broadcasts a login marker', async () => { + apiMocks.getCurrentUser.mockResolvedValue({ authenticated: true, name: 'cross-tab-user' }) + + const { useAuthStore } = await import('@/stores/auth') + const { sessionTokenManager } = await import('@/utils/sessionToken') + const store = useAuthStore() + + sessionTokenManager.setToken('authenticated') + + await vi.waitFor(() => { + expect(apiMocks.getCurrentUser).toHaveBeenCalledTimes(1) + expect(store.user).toEqual({ authenticated: true, name: 'cross-tab-user' }) + }) + }) + + it('clears auth state on cross-tab logout without needing a router', async () => { + const { useAuthStore } = await import('@/stores/auth') + const { sessionTokenManager } = await import('@/utils/sessionToken') + const store = useAuthStore() + + store.user = { authenticated: true, name: 'cross-tab-user' } + store.loaded = true + + sessionTokenManager.setToken('authenticated') + sessionTokenManager.clearToken() + + await vi.waitFor(() => { + expect(store.user.authenticated).toBe(false) + expect(store.loaded).toBe(true) + }) + expect(apiMocks.getCurrentUser).not.toHaveBeenCalled() + }) + + it('does not load the current user on initial empty auth marker state', async () => { + const { useAuthStore } = await import('@/stores/auth') + const store = useAuthStore() + + await Promise.resolve() + + expect(store.loaded).toBe(false) + expect(apiMocks.getCurrentUser).not.toHaveBeenCalled() + }) + + it('clears a stale browser auth marker when /account/me reports unauthenticated', async () => { + apiMocks.getCurrentUser.mockResolvedValue({ authenticated: false }) + + const { useAuthStore } = await import('@/stores/auth') + const { sessionTokenManager } = await import('@/utils/sessionToken') + const store = useAuthStore() + sessionTokenManager.setToken('authenticated') + + await store.loadCurrentUser() + + expect(store.user.authenticated).toBe(false) + expect(sessionTokenManager.getToken()).toBeNull() + }) + + it('clears local auth data when logout fails', async () => { + apiMocks.logout.mockRejectedValue(new Error('logout failed')) + + const { useAuthStore } = await import('@/stores/auth') + const { sessionTokenManager } = await import('@/utils/sessionToken') + const store = useAuthStore() + sessionTokenManager.setToken('authenticated') + store.user = { authenticated: true, name: 'user' } + + await store.logout() + + expect(captureException).toHaveBeenCalled() + expect(clearAllAuthData).toHaveBeenCalled() + expect(sessionTokenManager.getToken()).toBeNull() + expect(store.user.authenticated).toBe(false) + expect(store.loaded).toBe(true) + }) +}) 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..ce0bbca5f --- /dev/null +++ b/fe/src/test/README.md @@ -0,0 +1,33 @@ +# 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. +- Keep auth boundaries explicit: auth store specs cover state, API calls, and + browser auth markers without a real router; router, login view, and app-shell + specs own redirect behavior. +- 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 58% rename from fe/src/__tests__/AppActivityBadge.spec.ts rename to fe/src/test/app/AppActivityBadge.spec.ts index bc81ee3de..f7d49c03f 100644 --- a/fe/src/__tests__/AppActivityBadge.spec.ts +++ b/fe/src/test/app/AppActivityBadge.spec.ts @@ -15,10 +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, vi, beforeEach, afterEach } from 'vitest' -import { mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' import { computed, ref } from 'vue' import { createPinia, setActivePinia } from 'pinia' +import { delay, flushAsync } from '@/test/utils/wait' + +enableAutoUnmount(afterEach) // Mock the downloads store so App.vue picks up the activeDownloads correctly vi.mock('@/stores/downloads', () => ({ @@ -52,6 +55,10 @@ vi.mock('@/services/signalr', () => ({ }, })) +vi.mock('@/router', () => ({ + preloadRoute: vi.fn(async () => undefined), +})) + // Mock API calls used during mount - only return what tests need vi.mock('@/services/api', () => ({ apiService: { @@ -63,29 +70,17 @@ vi.mock('@/services/api', () => ({ }, })) -vi.mock('@/router', () => ({ - preloadRoute: vi.fn(), -})) - import { createRouter, createMemoryHistory } from 'vue-router' describe('App.vue activity badge', () => { - let wrapper: VueWrapper | undefined - beforeEach(() => { // reset mocks between tests vi.resetModules() setActivePinia(createPinia()) }) - afterEach(() => { - wrapper?.unmount() - wrapper = undefined - vi.clearAllMocks() - }) - // 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, @@ -143,15 +138,15 @@ describe('App.vue activity badge', () => { await router.push('/') await router.isReady().catch(() => {}) - wrapper = mount(AppComponent, { + const wrapper = mount(AppComponent, { global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) // 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) @@ -193,14 +188,14 @@ describe('App.vue activity badge', () => { await router.push('/') await router.isReady().catch(() => {}) - wrapper = mount(AppComponent, { + const wrapper = mount(AppComponent, { global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) // 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) }) @@ -257,14 +252,14 @@ describe('App.vue activity badge', () => { await router.push('/') await router.isReady().catch(() => {}) - wrapper = mount(AppComponent, { + const wrapper = mount(AppComponent, { global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, }) // 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) @@ -294,13 +289,13 @@ describe('App.vue activity badge', () => { await router.push('/') await router.isReady().catch(() => {}) - wrapper = mount(AppComponent, { + const wrapper = mount(AppComponent, { 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() @@ -349,20 +344,256 @@ describe('App.vue activity badge', () => { await router.push('/') await router.isReady().catch(() => {}) - wrapper = mount(AppComponent, { + const wrapper = mount(AppComponent, { 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) }) + + it('hides app chrome on the login route', async () => { + vi.resetModules() + vi.doMock('@/services/startupConfigCache', () => ({ + getStartupConfigCached: vi.fn(async () => ({ authenticationRequired: true })), + })) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + name: 'home', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/login', + name: 'login', + component: { template: '
' }, + meta: { hideLayout: true }, + }, + ], + }) + await router.push('/login') + await router.isReady().catch(() => {}) + + const wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await flushAsync() + + expect(wrapper.find('header.top-nav').exists()).toBe(false) + expect(wrapper.find('main.main-content.full-page').exists()).toBe(true) + }) + + it('redirects from a protected route when auth state becomes unauthenticated', async () => { + vi.resetModules() + const { reactive } = await import('vue') + const authStore = { + user: reactive({ authenticated: true }), + loadCurrentUser: vi.fn(async () => undefined), + logout: vi.fn(async () => { + authStore.user.authenticated = false + }), + } + + vi.doMock('@/stores/auth', () => ({ + useAuthStore: () => authStore, + })) + vi.doMock('@/services/startupConfigCache', () => ({ + getStartupConfigCached: vi.fn(async () => ({ authenticationRequired: true })), + })) + vi.doMock('@/services/api', () => ({ + apiService: { + getQueue: async () => [], + getServiceHealth: async () => ({ version: '0.0.0' }), + getBootstrapConfig: async () => ({ authenticationRequired: true }), + getStartupConfig: async () => ({ authenticationRequired: true }), + getLibrary: async () => [], + }, + })) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + name: 'home', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/settings', + name: 'settings', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/login', + name: 'login', + component: { template: '
' }, + meta: { hideLayout: true }, + }, + ], + }) + await router.push('/settings') + await router.isReady().catch(() => {}) + + mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await flushAsync() + + authStore.user.authenticated = false + await flushAsync() + + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/settings') + }) + + it('does not force login when auth is disabled and shows the disabled-auth banner', async () => { + vi.resetModules() + const { reactive } = await import('vue') + const authStore = { + user: reactive({ authenticated: true }), + loadCurrentUser: vi.fn(async () => undefined), + logout: vi.fn(async () => { + authStore.user.authenticated = false + }), + } + + vi.doMock('@/stores/auth', () => ({ + useAuthStore: () => authStore, + })) + vi.doMock('@/services/startupConfigCache', () => ({ + getStartupConfigCached: vi.fn(async () => ({ authenticationRequired: false })), + })) + vi.doMock('@/services/api', () => ({ + apiService: { + getQueue: async () => [], + getServiceHealth: async () => ({ version: '0.0.0' }), + getBootstrapConfig: async () => ({ authenticationRequired: false }), + getStartupConfig: async () => ({ authenticationRequired: false }), + getLibrary: async () => [], + }, + })) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + name: 'home', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/settings', + name: 'settings', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/login', + name: 'login', + component: { template: '
' }, + meta: { hideLayout: true }, + }, + ], + }) + await router.push('/settings') + await router.isReady().catch(() => {}) + + const wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await flushAsync() + + authStore.user.authenticated = false + await flushAsync() + + expect(router.currentRoute.value.name).toBe('settings') + expect(wrapper.find('.security-warning-banner').exists()).toBe(true) + }) + + it('logout button calls auth logout and uses app auth navigation', async () => { + vi.resetModules() + const { reactive } = await import('vue') + const authStore = { + user: reactive({ authenticated: true }), + loadCurrentUser: vi.fn(async () => undefined), + logout: vi.fn(async () => { + authStore.user.authenticated = false + }), + } + + vi.doMock('@/stores/auth', () => ({ + useAuthStore: () => authStore, + })) + vi.doMock('@/services/startupConfigCache', () => ({ + getStartupConfigCached: vi.fn(async () => ({ authenticationRequired: true })), + })) + vi.doMock('@/services/api', () => ({ + apiService: { + getQueue: async () => [], + getServiceHealth: async () => ({ version: '0.0.0' }), + getBootstrapConfig: async () => ({ authenticationRequired: true }), + getStartupConfig: async () => ({ authenticationRequired: true }), + getLibrary: async () => [], + }, + })) + + const { default: AppComponent } = await import('@/App.vue') + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/', + name: 'home', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/settings', + name: 'settings', + component: { template: '
' }, + meta: { requiresAuth: true }, + }, + { + path: '/login', + name: 'login', + component: { template: '
' }, + meta: { hideLayout: true }, + }, + ], + }) + await router.push('/settings') + await router.isReady().catch(() => {}) + + const wrapper = mount(AppComponent, { + global: { stubs: ['RouterLink', 'RouterView'], plugins: [createPinia(), router] }, + }) + await flushAsync() + + await wrapper.find('.nav-user-btn').trigger('click') + await wrapper.find('.user-menu-item').trigger('click') + await flushAsync() + + expect(authStore.logout).toHaveBeenCalledTimes(1) + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/settings') + }) }) 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/utils/authConfig.ts b/fe/src/utils/authConfig.ts new file mode 100644 index 000000000..d5a7d1f94 --- /dev/null +++ b/fe/src/utils/authConfig.ts @@ -0,0 +1,53 @@ +/* + * 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 . + */ + +type AuthConfigLike = { + authenticationRequired?: string | boolean | null + AuthenticationRequired?: string | boolean | null +} + +export function parseAuthRequiredValue(value: unknown): boolean | null { + if (typeof value === 'boolean') return value + + if (typeof value === 'string') { + const normalized = value.toLowerCase().trim() + if ( + normalized === 'enabled' || + normalized === 'true' || + normalized === 'yes' || + normalized === '1' + ) { + return true + } + if ( + normalized === 'disabled' || + normalized === 'false' || + normalized === 'no' || + normalized === '0' + ) { + return false + } + } + + return null +} + +export function parseAuthRequiredFromConfig(config: AuthConfigLike | null | undefined) { + const raw = config?.authenticationRequired ?? config?.AuthenticationRequired + return parseAuthRequiredValue(raw) +} diff --git a/fe/src/utils/redirect.ts b/fe/src/utils/redirect.ts index 5243df5d0..27d71c4fe 100644 --- a/fe/src/utils/redirect.ts +++ b/fe/src/utils/redirect.ts @@ -34,3 +34,21 @@ export function isSafeRedirect(path: string | undefined | null): boolean { export function normalizeRedirect(path: string | undefined | null): string { return isSafeRedirect(path) ? (path as string) : '/' } + +export function redirectLocationFromPath(path: string | undefined | null) { + const safeRedirect = normalizeRedirect(path) + if (safeRedirect === '/') { + return { name: 'home' } + } + + try { + const url = new URL(safeRedirect, window.location.origin) + return { + path: url.pathname, + query: Object.fromEntries(url.searchParams), + hash: url.hash, + } + } catch { + return { path: safeRedirect } + } +} 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__/seriesUtils.spec.ts b/fe/src/utils/test/seriesUtils.spec.ts similarity index 100% rename from fe/src/__tests__/seriesUtils.spec.ts rename to fe/src/utils/test/seriesUtils.spec.ts diff --git a/fe/src/__tests__/sessionTokenStorage.test.ts b/fe/src/utils/test/sessionTokenStorage.spec.ts similarity index 93% rename from fe/src/__tests__/sessionTokenStorage.test.ts rename to fe/src/utils/test/sessionTokenStorage.spec.ts index e59d99f75..a58b05ad6 100644 --- a/fe/src/__tests__/sessionTokenStorage.test.ts +++ b/fe/src/utils/test/sessionTokenStorage.spec.ts @@ -15,11 +15,14 @@ * 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 { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { installStorageMock } from '@/test/utils/storage' import { sessionTokenManager } from '@/utils/sessionToken' describe('sessionTokenManager storage propagation', () => { beforeEach(() => { + vi.resetModules() + installStorageMock() // Ensure clean browser auth state before each test. try { sessionTokenManager.clearToken() @@ -40,7 +43,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/views/SettingsView.vue b/fe/src/views/SettingsView.vue index 11e4e2ca5..0d08f5a3c 100644 --- a/fe/src/views/SettingsView.vue +++ b/fe/src/views/SettingsView.vue @@ -919,7 +919,6 @@ const saveSettings = async () => { sessionTokenManager.clearToken() } catch {} auth.user.authenticated = false - auth.redirectTo = null try { await apiService.ensureAntiforgeryForCurrentAuth() } catch {} 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/views/auth/LoginView.vue b/fe/src/views/auth/LoginView.vue index 31129cca6..0ad12bd53 100644 --- a/fe/src/views/auth/LoginView.vue +++ b/fe/src/views/auth/LoginView.vue @@ -73,6 +73,8 @@ import Checkbox from '@/components/form/Checkbox.vue' import { apiService } from '@/services/api' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { parseAuthRequiredFromConfig } from '@/utils/authConfig' +import { normalizeRedirect, redirectLocationFromPath } from '@/utils/redirect' // Vite static import for the logo so bundler resolves the asset reliably export default defineComponent({ @@ -96,17 +98,8 @@ export default defineComponent({ if (startupConfigChecked.value) return try { const sc = await apiService.getBootstrapConfig() - const rawAuth = - sc?.authenticationRequired ?? - (sc as unknown as Record)?.AuthenticationRequired - const authEnabled = - typeof rawAuth === 'boolean' - ? rawAuth - : typeof rawAuth === 'string' - ? rawAuth.toLowerCase() === 'enabled' || rawAuth.toLowerCase() === 'true' - : false startupConfigChecked.value = true - if (!authEnabled) { + if (!(parseAuthRequiredFromConfig(sc) ?? false)) { await router.replace({ name: 'home' }) } } catch { @@ -119,19 +112,13 @@ export default defineComponent({ retrySeconds.value = null loading.value = true - // Fetch CSRF token - const token = await apiService.fetchAntiforgeryToken() - try { + // Fetch CSRF token + const token = await apiService.fetchAntiforgeryToken() await auth.login(username.value, password.value, rememberMe.value, token ?? undefined) - // On success, prefer query param redirect (survives reload); fallback to store, then home - // Prefer explicit query param redirect (survives reload). If missing, try the - // fallback stored in sessionStorage by the ApiService when it had to perform - // a full-page redirect. Always sanitize the redirect target. const rawQueryRedirect = (router.currentRoute.value.query?.redirect as string | undefined) ?? undefined - const { normalizeRedirect } = await import('@/utils/redirect') let queryRedirect = normalizeRedirect(rawQueryRedirect) if (!queryRedirect || queryRedirect === '/') { @@ -144,12 +131,7 @@ export default defineComponent({ } catch {} } - const dest = - queryRedirect && queryRedirect !== '/' - ? { path: queryRedirect } - : (auth.redirectTo ?? { name: 'home' }) - auth.redirectTo = null - await router.push(dest) + await router.push(redirectLocationFromPath(queryRedirect)) } catch (err) { interface LoginError { status?: number diff --git a/fe/src/views/auth/test/LoginView.spec.ts b/fe/src/views/auth/test/LoginView.spec.ts new file mode 100644 index 000000000..8613b0ab1 --- /dev/null +++ b/fe/src/views/auth/test/LoginView.spec.ts @@ -0,0 +1,116 @@ +/* + * 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 { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import LoginView from '@/views/auth/LoginView.vue' +import { flushAsync } from '@/test/utils/wait' + +const apiService = vi.hoisted(() => ({ + fetchAntiforgeryToken: vi.fn(), + getBootstrapConfig: vi.fn(), +})) + +const auth = vi.hoisted(() => ({ + login: vi.fn(), +})) + +vi.mock('@/services/api', () => ({ + apiService, +})) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => auth, +})) + +const routes = [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/login', name: 'login', component: LoginView }, + { path: '/settings', name: 'settings', component: { template: '
' } }, + { path: '/wanted', name: 'wanted', component: { template: '
' } }, +] + +async function mountLogin(initialPath = '/login') { + const router = createRouter({ + history: createMemoryHistory(), + routes, + }) + await router.push(initialPath) + await router.isReady() + + const wrapper = mount(LoginView, { + global: { + plugins: [router], + }, + }) + await flushAsync() + + return { router, wrapper } +} + +describe('LoginView auth redirects', () => { + beforeEach(() => { + apiService.fetchAntiforgeryToken.mockReset() + apiService.fetchAntiforgeryToken.mockResolvedValue('csrf') + apiService.getBootstrapConfig.mockReset() + apiService.getBootstrapConfig.mockResolvedValue({ authenticationRequired: true }) + auth.login.mockReset() + auth.login.mockResolvedValue(undefined) + sessionStorage.removeItem('listenarr_pending_redirect') + }) + + it('uses a safe query redirect after successful login', async () => { + const { router, wrapper } = await mountLogin( + '/login?redirect=/settings%3Ftab%3Dgeneral%23indexers', + ) + + await (wrapper.vm as unknown as { onSubmit: () => Promise }).onSubmit() + + expect(auth.login).toHaveBeenCalled() + expect(router.currentRoute.value.path).toBe('/settings') + expect(router.currentRoute.value.query.tab).toBe('general') + expect(router.currentRoute.value.hash).toBe('#indexers') + }) + + it('uses the pending session redirect when query redirect is absent', async () => { + sessionStorage.setItem('listenarr_pending_redirect', '/wanted') + const { router, wrapper } = await mountLogin('/login') + + await (wrapper.vm as unknown as { onSubmit: () => Promise }).onSubmit() + + expect(router.currentRoute.value.name).toBe('wanted') + }) + + it('falls back home when redirect values are unsafe', async () => { + sessionStorage.setItem('listenarr_pending_redirect', 'https://evil.example/') + const { router, wrapper } = await mountLogin('/login?redirect=https%3A%2F%2Fevil.example%2F') + + await (wrapper.vm as unknown as { onSubmit: () => Promise }).onSubmit() + + expect(router.currentRoute.value.name).toBe('home') + }) + + it('redirects away from login when authentication is disabled', async () => { + apiService.getBootstrapConfig.mockResolvedValue({ authenticationRequired: false }) + + const { router } = await mountLogin('/login') + await flushAsync() + + expect(router.currentRoute.value.name).toBe('home') + }) +}) diff --git a/fe/src/__tests__/AddNewView.spec.ts b/fe/src/views/content/test/AddNewView.spec.ts similarity index 87% rename from fe/src/__tests__/AddNewView.spec.ts rename to fe/src/views/content/test/AddNewView.spec.ts index d0a82fd3b..ba28bbb7f 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('renders direct image URLs 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,7 +403,7 @@ 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') }) @@ -466,7 +506,7 @@ describe('AddNewView pagination', () => { 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: [ @@ -482,7 +522,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[] @@ -495,18 +535,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 } @@ -518,7 +558,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) @@ -531,7 +571,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: [ @@ -548,7 +588,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 @@ -561,13 +601,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: [ @@ -584,7 +624,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 @@ -596,13 +636,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: [ @@ -619,7 +659,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 @@ -642,14 +682,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' }], @@ -680,13 +720,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', @@ -701,7 +741,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')) { @@ -712,7 +752,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' @@ -732,7 +772,7 @@ describe('AddNewView pagination', () => { // ASIN result case vm.searchType = 'asin' - ;(vm as unknown).audibleResult = { + ;(vm as any).audibleResult = { asin: 'BAUD2', title: 'B', series: 'X', @@ -747,7 +787,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 80% rename from fe/src/__tests__/AudiobooksView.spec.ts rename to fe/src/views/library/test/AudiobooksView.spec.ts index 718836de9..7dadc37fb 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', () => ({ @@ -41,27 +44,72 @@ type AudiobooksVm = { visibleRange?: { start: number; end: number } } -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 */ } } @@ -89,13 +137,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: [ @@ -107,7 +155,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') @@ -127,16 +175,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 */ } } @@ -179,10 +225,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: [ @@ -194,7 +240,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Set groupBy to authors const vm = getVm(wrapper) @@ -215,21 +261,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 */ } } @@ -272,10 +316,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: [ @@ -287,7 +331,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Set groupBy to series const vm = getVm(wrapper) @@ -326,10 +370,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: [ @@ -341,22 +385,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') @@ -380,14 +424,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) @@ -399,16 +443,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 */ } } @@ -435,12 +477,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: [ @@ -452,7 +494,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // groupBy defaults to 'books' const vm = getVm(wrapper) @@ -477,10 +519,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: [ @@ -493,7 +535,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' @@ -521,16 +563,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 */ } } @@ -560,10 +600,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: [ @@ -575,10 +615,10 @@ 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('resets the virtual range when returning to books grouping', async () => { @@ -642,16 +682,14 @@ describe('AudiobooksView Grouping', () => { }) 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 */ } } @@ -686,10 +724,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: [ @@ -701,7 +739,7 @@ describe('AudiobooksView Grouping', () => { ], }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() // Select one item store.toggleSelection(1) @@ -715,16 +753,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 */ } } @@ -762,10 +798,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: [ @@ -777,7 +813,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 97% rename from fe/src/__tests__/CollectionView.spec.ts rename to fe/src/views/library/test/CollectionView.spec.ts index 0ce980bd5..9516d8b0c 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, @@ -157,7 +158,7 @@ describe('CollectionView', () => { addedCount: 0, existingCount: 0, failedCount: 0, - }) + } as any) mockMonitorSeries.mockReset() mockMonitorSeries.mockResolvedValue({ message: 'Series monitoring enabled', @@ -172,7 +173,7 @@ describe('CollectionView', () => { addedCount: 0, existingCount: 0, failedCount: 0, - }) + } as any) mockUnmonitorAuthor.mockReset() mockUnmonitorAuthor.mockResolvedValue({ message: 'Author monitoring disabled' }) mockUnmonitorSeries.mockReset() @@ -183,16 +184,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 */ } } @@ -228,7 +227,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, { @@ -237,7 +236,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') @@ -292,7 +291,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, { @@ -301,7 +300,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) @@ -350,7 +349,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, { @@ -359,7 +358,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) @@ -408,7 +407,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, { @@ -417,7 +416,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) @@ -444,7 +443,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) @@ -455,7 +454,7 @@ describe('CollectionView', () => { }, }) - await new Promise((r) => setTimeout(r, 0)) + await flushAsync() const toolbar = wrapper.find('.toolbar') expect(toolbar.exists()).toBe(true) @@ -533,7 +532,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) @@ -639,7 +638,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) @@ -909,7 +908,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) @@ -1019,7 +1018,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) @@ -1121,7 +1120,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) @@ -1200,7 +1199,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) @@ -1258,7 +1257,7 @@ describe('CollectionView', () => { addedCount: 1, existingCount: 0, failedCount: 0, - }) + } as any) const router = createRouter({ history: createMemoryHistory(), @@ -1272,7 +1271,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, { @@ -1341,7 +1340,7 @@ describe('CollectionView', () => { addedCount: 1, existingCount: 0, failedCount: 0, - }) + } as any) const router = createRouter({ history: createMemoryHistory(), @@ -1355,7 +1354,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, { @@ -1441,7 +1440,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 74% rename from fe/src/__tests__/IndexersTab.spec.ts rename to fe/src/views/settings/test/IndexersTab.spec.ts index 664e0fe2e..b1974a84e 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', () => { @@ -41,7 +43,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: '', @@ -52,9 +54,17 @@ describe('IndexersTab', () => { } }) + // ensure signalRService has the onIndexersUpdated helper (some test setup + // imports the module earlier so we patch the existing export) + const sr = await import('@/services/signalr') + // provide a no-op subscription function + if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { + ;(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() @@ -64,7 +74,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) @@ -78,7 +88,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', @@ -89,17 +99,22 @@ describe('IndexersTab', () => { } }) + const sr = await import('@/services/signalr') + if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { + ;(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( @@ -127,7 +142,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', @@ -139,23 +154,26 @@ describe('IndexersTab', () => { } }) + const sr = await import('@/services/signalr') + if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { + ;(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', @@ -178,7 +196,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', @@ -190,22 +208,25 @@ describe('IndexersTab', () => { } }) + const sr = await import('@/services/signalr') + if (!sr.signalRService || typeof sr.signalRService.onIndexersUpdated !== 'function') { + ;(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 64% rename from fe/src/__tests__/SettingsView.spec.ts rename to fe/src/views/test/SettingsView.spec.ts index aa7b3e360..c12a277a1 100644 --- a/fe/src/__tests__/SettingsView.spec.ts +++ b/fe/src/views/test/SettingsView.spec.ts @@ -19,10 +19,16 @@ 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 }, + loadCurrentUser: vi.fn(async () => undefined), +})) vi.mock('@/services/api', () => ({ apiService: { @@ -58,6 +64,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 = { @@ -68,54 +78,29 @@ 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.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 @@ -124,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 @@ -172,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') @@ -205,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() }) @@ -249,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 0f3697ea5..516feabf4 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 822562d1e..1735b7bc5 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 d03bc51e4..e3ea05de3 100644 --- a/fe/vite.config.ts +++ b/fe/vite.config.ts @@ -28,6 +28,23 @@ export default defineConfig(({ mode }) => { ], build: { sourcemap: analyzeBundle, + 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 8a8a5516c..41bc70ca8 100644 --- a/fe/vitest.config.ts +++ b/fe/vitest.config.ts @@ -1,25 +1,50 @@ 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( typeof viteConfig === 'function' ? viteConfig(configEnv) : viteConfig, { + oxc: { + tsconfig: false, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, test: { - environment: 'jsdom', - setupFiles: './src/__tests__/test-setup.ts', + execArgv: ['--no-warnings'], + setupFiles: ['src/test/setup/signalr.ts'], + projects: testProjects, // Keep full-suite runs stable on Windows after dependency updates increase transform load. testTimeout: 30000, hookTimeout: 30000, // 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 8a2689bff..8afe3f3ef 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,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",