From 44410db492cd081d000014270e72d2e7ac39a11c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 12:34:58 -0400 Subject: [PATCH] test(I2): vitest component smoke tests for high-risk UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @vue/test-utils + happy-dom and mounts CredentialCard, DownloadEventRow, PostCard, ActiveDownloadsPanel, QueueStatusBar, asserting they render without throwing and surface key content — catching the dead-binding / render-error class (e.g. the last_verified_at regression, guarded explicitly). Vuetify components are left unresolved (Vue renders unknown elements + slots), so no Vuetify-plugin setup is needed; only RouterLink is stubbed. Per-file happy-dom env via docblock keeps the existing node-env specs untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/package.json | 4 ++- .../components/activeDownloadsPanel.spec.js | 30 +++++++++++++++++++ .../test/components/credentialCard.spec.js | 28 +++++++++++++++++ .../test/components/downloadEventRow.spec.js | 21 +++++++++++++ frontend/test/components/postCard.spec.js | 26 ++++++++++++++++ .../test/components/queueStatusBar.spec.js | 26 ++++++++++++++++ frontend/test/support/mountComponent.js | 24 +++++++++++++++ 7 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 frontend/test/components/activeDownloadsPanel.spec.js create mode 100644 frontend/test/components/credentialCard.spec.js create mode 100644 frontend/test/components/downloadEventRow.spec.js create mode 100644 frontend/test/components/postCard.spec.js create mode 100644 frontend/test/components/queueStatusBar.spec.js create mode 100644 frontend/test/support/mountComponent.js diff --git a/frontend/package.json b/frontend/package.json index d68122c..5e230c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,8 @@ "vue-tsc": "^2.0.0", "vite-plugin-vuetify": "^2.0.0", "sass": "^1.71.0", - "vitest": "^2.1.0" + "vitest": "^2.1.0", + "@vue/test-utils": "^2.4.0", + "happy-dom": "^15.0.0" } } diff --git a/frontend/test/components/activeDownloadsPanel.spec.js b/frontend/test/components/activeDownloadsPanel.spec.js new file mode 100644 index 0000000..e6e683e --- /dev/null +++ b/frontend/test/components/activeDownloadsPanel.spec.js @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import ActiveDownloadsPanel from '../../src/components/subscriptions/ActiveDownloadsPanel.vue' +import { useDownloadsStore } from '../../src/stores/downloads.js' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('ActiveDownloadsPanel', () => { + it('lists a running download with its artist', () => { + const pinia = freshPinia() + useDownloadsStore().activeEvents = [{ + id: 1, status: 'running', + started_at: new Date(Date.now() - 65000).toISOString(), + platform: 'patreon', artist_name: 'Alice', + }] + const w = mountComponent(ActiveDownloadsPanel, { pinia }) + const t = w.text() + expect(t).toContain('Alice') + expect(t).toContain('downloading') + w.unmount() // clear the 1s elapsed-timer interval + }) + + it('renders nothing when there is no active work', () => { + const pinia = freshPinia() + useDownloadsStore().activeEvents = [] + const w = mountComponent(ActiveDownloadsPanel, { pinia }) + expect(w.text()).toBe('') + w.unmount() + }) +}) diff --git a/frontend/test/components/credentialCard.spec.js b/frontend/test/components/credentialCard.spec.js new file mode 100644 index 0000000..f98d39b --- /dev/null +++ b/frontend/test/components/credentialCard.spec.js @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import CredentialCard from '../../src/components/subscriptions/CredentialCard.vue' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +const platform = { key: 'patreon', name: 'Patreon', auth_type: 'cookies' } + +describe('CredentialCard', () => { + it('renders the last-verified row when last_verified is set', () => { + // Regression guard: the field was once read as last_verified_at, so the + // row silently never rendered. + const pinia = freshPinia() + const recent = new Date(Date.now() - 2 * 86400_000).toISOString() + const credential = { + platform: 'patreon', credential_type: 'cookies', + captured_at: recent, expires_at: null, last_verified: recent, + } + const w = mountComponent(CredentialCard, { props: { platform, credential }, pinia }) + expect(w.text()).toContain('Last verified') + }) + + it('renders the empty state with no credential', () => { + const pinia = freshPinia() + const w = mountComponent(CredentialCard, { props: { platform, credential: null }, pinia }) + expect(w.text()).toContain('No credential stored') + }) +}) diff --git a/frontend/test/components/downloadEventRow.spec.js b/frontend/test/components/downloadEventRow.spec.js new file mode 100644 index 0000000..17d2151 --- /dev/null +++ b/frontend/test/components/downloadEventRow.spec.js @@ -0,0 +1,21 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import DownloadEventRow from '../../src/components/downloads/DownloadEventRow.vue' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('DownloadEventRow', () => { + it('renders the artist and the mapped status label', () => { + const pinia = freshPinia() + const now = new Date().toISOString() + const event = { + id: 1, status: 'ok', started_at: now, finished_at: now, + platform: 'patreon', artist_name: 'Alice', + files_count: 3, bytes_downloaded: 1000, error: null, summary: {}, + } + const w = mountComponent(DownloadEventRow, { props: { event }, pinia }) + const t = w.text() + expect(t).toContain('Alice') + expect(t).toContain('Completed') // downloadStatusLabel('ok') + }) +}) diff --git a/frontend/test/components/postCard.spec.js b/frontend/test/components/postCard.spec.js new file mode 100644 index 0000000..1e39004 --- /dev/null +++ b/frontend/test/components/postCard.spec.js @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import PostCard from '../../src/components/posts/PostCard.vue' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('PostCard', () => { + it('renders the HTML-stripped title and the artist', () => { + const pinia = freshPinia() + const now = new Date().toISOString() + const post = { + id: 1, external_post_id: 'P1', + post_title: 'Hello World', + post_url: 'https://x/1', post_date: now, downloaded_at: now, + description_plain: 'a description', + artist: { id: 1, name: 'Sabu', slug: 'sabu' }, + source: { id: 1, platform: 'subscribestar' }, + thumbnails: [], thumbnails_more: 0, attachments: [], + } + const w = mountComponent(PostCard, { props: { post }, pinia }) + const t = w.text() + expect(t).toContain('Hello World') // plain title + expect(t).not.toContain('') // tags stripped + expect(t).toContain('Sabu') // artist (RouterLink slot) + }) +}) diff --git a/frontend/test/components/queueStatusBar.spec.js b/frontend/test/components/queueStatusBar.spec.js new file mode 100644 index 0000000..cb65599 --- /dev/null +++ b/frontend/test/components/queueStatusBar.spec.js @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import QueueStatusBar from '../../src/components/settings/QueueStatusBar.vue' +import { useSystemActivityStore } from '../../src/stores/systemActivity.js' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('QueueStatusBar', () => { + it('shows the pending count when the queue is busy', () => { + const pinia = freshPinia() + useSystemActivityStore().queues = { queues: { ml: 3 }, fetched_at: '' } + const w = mountComponent(QueueStatusBar, { + props: { queue: 'ml', queueLabel: 'ML' }, pinia, + }) + expect(w.text()).toContain('3 pending') + }) + + it('reads idle when the queue is empty', () => { + const pinia = freshPinia() + useSystemActivityStore().queues = { queues: { ml: 0 }, fetched_at: '' } + const w = mountComponent(QueueStatusBar, { + props: { queue: 'ml', queueLabel: 'ML' }, pinia, + }) + expect(w.text().toLowerCase()).toContain('idle') + }) +}) diff --git a/frontend/test/support/mountComponent.js b/frontend/test/support/mountComponent.js new file mode 100644 index 0000000..888a70a --- /dev/null +++ b/frontend/test/support/mountComponent.js @@ -0,0 +1,24 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +// Smoke-test helper. Vuetify components are left unresolved (Vue 3 renders +// unknown elements with their slot children, so text content is still +// present and nothing throws), which avoids standing up the whole Vuetify +// plugin in happy-dom. RouterLink is the one import that needs a router, so +// it's stubbed with a slot-rendering anchor. + +export function freshPinia () { + const pinia = createPinia() + setActivePinia(pinia) + return pinia +} + +export function mountComponent (Component, { props = {}, pinia } = {}) { + return mount(Component, { + props, + global: { + plugins: pinia ? [pinia] : [], + stubs: { RouterLink: { template: '' } }, + }, + }) +}