test(I2): vitest component smoke tests for high-risk UI

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:34:58 -04:00
parent e76aa36a29
commit 44410db492
7 changed files with 158 additions and 1 deletions
+3 -1
View File
@@ -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"
}
}
@@ -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()
})
})
@@ -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')
})
})
@@ -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')
})
})
+26
View File
@@ -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: '<strong>Hello World</strong>',
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('<strong>') // tags stripped
expect(t).toContain('Sabu') // artist (RouterLink slot)
})
})
@@ -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')
})
})
+24
View File
@@ -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: '<a><slot /></a>' } },
},
})
}