import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import { h } from 'vue' import JobsStatusTray from '../../components/JobsStatusTray.vue' import type { PluginJobCenterSource } from '../../composables/useJobsStatusTray' import type { JobState, JobStateInput, JobStatus } from '../../types/jobs' function job(status: JobStatus, id: string = status): JobStateInput { return { id, job_id: id, kind: 'analysis', name: `Job ${id}`, status, progress: { current_file: 0, total_files: 0, current_filename: null, percent: 0, message: '', stage: status, }, queue_position: 0, warnings: [], error: null, output_path: null, save_path: null, session_id: null, folders: [], folder_path: null, user_id: '1', owner_user_id: '1', experiment_id: null, extraction_result_id: null, metadata: {}, created_at: '2026-07-14T10:00:00+00:00', started_at: null, completed_at: null, } } async function openTray(options: { source: PluginJobCenterSource slots?: Record unknown> }) { const { slots, ...props } = options const wrapper = mount(JobsStatusTray, { attachTo: document.body, props: { ...props, loadOnMount: false, teleportTo: false, }, slots, }) await wrapper.get('.mint-jobs-tray__trigger').trigger('click') return wrapper } describe('JobsStatusTray', () => { it('shows awaiting input as active without default actions', async () => { const wrapper = await openTray({ source: { jobs: [job('awaiting_input')] } }) expect([ wrapper.text().includes('Waiting for input'), wrapper.find('[aria-label="Cancel job"]').exists(), wrapper.find('[aria-label="Delete job"]').exists(), ]).toEqual([true, false, false]) }) it('shows only actions supported by both capability and source', async () => { const wrapper = await openTray({ source: { jobs: [ { ...job('awaiting_input', 'input'), can_cancel: true }, { ...job('completed', 'done'), can_delete: true }, ], cancelJob: vi.fn(async () => undefined), deleteJob: vi.fn(async () => true), }, }) expect([ wrapper.find('[aria-label="Cancel job"]').exists(), wrapper.find('[aria-label="Delete job"]').exists(), ]).toEqual([true, false]) await wrapper.get('[role="tab"][data-tab="history"]').trigger('click') expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true) }) it('exposes dialog state and closes on Escape', async () => { const wrapper = await openTray({ source: { jobs: [job('running')] } }) const trigger = wrapper.get('.mint-jobs-tray__trigger') await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Escape' }) expect([trigger.attributes('aria-expanded'), wrapper.find('[role="dialog"]').exists()]).toEqual([ 'false', false, ]) }) it('renders an individual delete action for an active capable job', async () => { const wrapper = await openTray({ source: { jobs: [{ ...job('awaiting_input'), can_delete: true }], deleteJob: vi.fn(async () => true), }, }) expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true) }) it('traps forward Tab navigation inside the modal dialog', async () => { const wrapper = await openTray({ source: { jobs: [job('running')] } }) const focusable = wrapper.findAll('button') const firstInsidePanel = wrapper.get('[aria-label="Close jobs"]') const lastInsidePanel = focusable[focusable.length - 1]! lastInsidePanel.element.focus() await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Tab' }) expect(document.activeElement).toBe(firstInsidePanel.element) }) it('traps reverse Tab navigation from the dialog container', async () => { const wrapper = await openTray({ source: { jobs: [job('running')] } }) const panel = wrapper.get('[role="dialog"]') const focusable = panel.findAll('button') const panelElement = panel.element as HTMLElement panelElement.focus() await panel.trigger('keydown', { key: 'Tab', shiftKey: true }) expect(document.activeElement).toBe(focusable[focusable.length - 1]!.element) }) it('exposes job selection through a keyboard-accessible button', async () => { const wrapper = await openTray({ source: { jobs: [job('running')] } }) await wrapper.get('.mint-jobs-tray__job-select').trigger('click') expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({ job_id: 'running' }) }) it('renders the job-actions slot once per row in both tabs with the row job', async () => { const wrapper = await openTray({ source: { jobs: [job('running', 'R-1'), job('completed', 'C-1')] }, slots: { 'job-actions': ({ job: row }) => h('button', { 'data-testid': 'open', 'data-job': row.job_id }, 'Open'), }, }) const active = wrapper.findAll('[data-testid="open"]') expect(active.map((b) => b.attributes('data-job'))).toEqual(['R-1']) await wrapper.get('[role="tab"][data-tab="history"]').trigger('click') const history = wrapper.findAll('[data-testid="open"]') expect(history.map((b) => b.attributes('data-job'))).toEqual(['C-1']) }) it('should pin awaiting-input jobs to the top when they arrive after other active jobs', async () => { const wrapper = await openTray({ source: { jobs: [job('running', 'R-1'), job('queued', 'Q-1'), job('awaiting_input', 'A-1')] }, }) expect(wrapper.findAll('.mint-jobs-tray__job-select').map((b) => b.text())) .toEqual(['Job A-1', 'Job R-1', 'Job Q-1']) }) it('should tint the awaiting-input row when a job waits for input', async () => { const wrapper = await openTray({ source: { jobs: [job('awaiting_input')] } }) expect(wrapper.get('.mint-jobs-tray__job').classes()).toContain('mint-jobs-tray__job--awaiting-input') }) it('should show a mono percent next to the status when a job is running', async () => { const running = job('running') running.progress = { ...running.progress, percent: 62 } const wrapper = await openTray({ source: { jobs: [running] } }) expect(wrapper.get('.mint-jobs-tray__percent').text()).toBe('62%') }) it('should summarize active jobs by state in the header when jobs are active', async () => { const wrapper = await openTray({ source: { jobs: [job('running', 'R-1'), job('queued', 'Q-1'), job('awaiting_input', 'A-1')] }, }) expect(wrapper.get('.mint-jobs-tray__subtitle').text()).toBe('1 running · 1 queued · 1 waiting') }) })