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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>' } },
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user