diff --git a/apps/website/public/favicon.svg b/apps/website/public/favicon.svg new file mode 100644 index 0000000000..f41c8445d7 --- /dev/null +++ b/apps/website/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro index d46e85c7b3..5397d96820 100644 --- a/apps/website/src/layouts/BaseLayout.astro +++ b/apps/website/src/layouts/BaseLayout.astro @@ -71,7 +71,7 @@ const websiteJsonLd = { {noindex && } {title} - + diff --git a/browser_tests/fixtures/components/QueuePanel.ts b/browser_tests/fixtures/components/QueuePanel.ts index 525af60ee0..819a68fa0a 100644 --- a/browser_tests/fixtures/components/QueuePanel.ts +++ b/browser_tests/fixtures/components/QueuePanel.ts @@ -5,11 +5,13 @@ import { TestIds } from '@e2e/fixtures/selectors' export class QueuePanel { readonly overlayToggle: Locator + readonly overlay: Locator readonly moreOptionsButton: Locator constructor(readonly page: Page) { this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle) - this.moreOptionsButton = page.getByLabel(/More options/i).first() + this.overlay = page.getByTestId(TestIds.queue.progressOverlay) + this.moreOptionsButton = this.overlay.getByLabel(/More options/i) } async openClearHistoryDialog() { diff --git a/browser_tests/fixtures/helpers/LogsTerminalHelper.ts b/browser_tests/fixtures/helpers/LogsTerminalHelper.ts index b52db5198e..1980c55ed5 100644 --- a/browser_tests/fixtures/helpers/LogsTerminalHelper.ts +++ b/browser_tests/fixtures/helpers/LogsTerminalHelper.ts @@ -1,19 +1,26 @@ -import { test as base } from '@playwright/test' -import type { Page, Route } from '@playwright/test' +import { test as base, expect } from '@playwright/test' +import type { Page, Route, WebSocketRoute } from '@playwright/test' import type { LogsRawResponse } from '@/schemas/apiSchema' +const RAW_LOGS_URL = '**/internal/logs/raw**' +const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**' + export class LogsTerminalHelper { constructor(private readonly page: Page) {} - async mockRawLogs(messages: string[]) { - await this.page.route('**/internal/logs/raw**', (route: Route) => - route.fulfill({ + async mockRawLogs(messages: string[]): Promise<() => number> { + let count = 0 + await this.page.unroute(RAW_LOGS_URL) + await this.page.route(RAW_LOGS_URL, async (route: Route) => { + count += 1 + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages)) }) - ) + }) + return () => count } async mockRawLogsPending(messages: string[] = []): Promise<() => void> { @@ -21,7 +28,8 @@ export class LogsTerminalHelper { const pending = new Promise((r) => { resolve = r }) - await this.page.route('**/internal/logs/raw**', async (route: Route) => { + await this.page.unroute(RAW_LOGS_URL) + await this.page.route(RAW_LOGS_URL, async (route: Route) => { await pending await route.fulfill({ status: 200, @@ -33,15 +41,39 @@ export class LogsTerminalHelper { } async mockRawLogsError() { - await this.page.route('**/internal/logs/raw**', (route: Route) => + await this.page.unroute(RAW_LOGS_URL) + await this.page.route(RAW_LOGS_URL, (route: Route) => route.fulfill({ status: 500, body: 'Internal Server Error' }) ) } - async mockSubscribeLogs() { - await this.page.route('**/internal/logs/subscribe**', (route: Route) => - route.fulfill({ status: 200, body: '' }) - ) + async mockSubscribeLogs(): Promise<() => number> { + let count = 0 + await this.page.unroute(SUBSCRIBE_LOGS_URL) + await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => { + count += 1 + await route.fulfill({ status: 200, body: '' }) + }) + return () => count + } + + /** + * Force the frontend to reconnect by closing the proxied WebSocket. The + * api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket + * handler fires again, and on `open` with `isReconnect=true` it dispatches + * `'reconnected'`, which triggers the logs-terminal resync. + * + * Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by + * the time the count goes up, the new socket is open and resync has + * completed enough to assert against the terminal. + */ + async triggerReconnect( + ws: WebSocketRoute, + subscribeFetches: () => number + ): Promise { + const before = subscribeFetches() + await ws.close() + await expect.poll(subscribeFetches).toBeGreaterThan(before) } static buildWsLogFrame(messages: string[]): string { diff --git a/browser_tests/fixtures/jobsRouteFixture.ts b/browser_tests/fixtures/jobsRouteFixture.ts index 5128a2f397..11e9b4e4e7 100644 --- a/browser_tests/fixtures/jobsRouteFixture.ts +++ b/browser_tests/fixtures/jobsRouteFixture.ts @@ -1,6 +1,11 @@ import { test as base } from '@playwright/test' import type { Page } from '@playwright/test' import type { z } from 'zod' +import { + zHistoryManageRequest, + zQueueManageRequest, + zQueueManageResponse +} from '@comfyorg/ingest-types/zod' import type { JobStatus, @@ -9,6 +14,8 @@ import type { } from '@/platform/remote/comfyui/jobs/jobTypes' type JobsListResponse = z.infer +type HistoryManageRequest = z.infer +type QueueManageRequest = z.infer const terminalJobStatuses = [ 'completed', @@ -22,7 +29,8 @@ const activeJobStatuses = [ const defaultJobsListLimit = 200 const defaultScenarioHistoryLimit = 64 const defaultJobsListOffset = 0 -const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12) + +export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12) interface JobsListRoute { statuses: readonly JobStatus[] @@ -65,11 +73,9 @@ function hasJobsListPageParams( ) } -function isJobsListRequest(url: URL, route: JobsListRoute): boolean { +function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean { return ( - url.pathname.endsWith('/api/jobs') && - hasExactStatuses(url, route.statuses) && - hasJobsListPageParams(url, route) + hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route) ) } @@ -99,9 +105,9 @@ export function createRouteMockJob({ return { id, status: 'completed', - create_time: defaultRouteMockJobTimestamp, - execution_start_time: defaultRouteMockJobTimestamp, - execution_end_time: defaultRouteMockJobTimestamp + 5_000, + create_time: routeMockJobTimestamp, + execution_start_time: routeMockJobTimestamp, + execution_end_time: routeMockJobTimestamp + 5_000, preview_output: { filename: `output_${id}.png`, subfolder: '', @@ -150,7 +156,8 @@ export class JobsRouteMocker { const response = createJobsListResponse(route) await this.page.route( - (url) => isJobsListRequest(url, route), + (url) => + url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route), async (requestRoute) => { if (requestRoute.request().method().toUpperCase() !== 'GET') { await requestRoute.fallback() @@ -161,6 +168,44 @@ export class JobsRouteMocker { } ) } + + async mockClearQueue(): Promise { + const response = zQueueManageResponse.parse({ cleared: true }) + return await this.mockPostManageRoute( + 'queue', + zQueueManageRequest, + response + ) + } + + async mockClearHistory(): Promise { + return await this.mockPostManageRoute('history', zHistoryManageRequest, {}) + } + + private async mockPostManageRoute( + type: 'queue' | 'history', + requestSchema: z.ZodType, + response: unknown + ): Promise { + const requests: TRequest[] = [] + + await this.page.route( + (url) => url.pathname.endsWith(`/api/${type}`), + async (requestRoute) => { + if (requestRoute.request().method().toUpperCase() !== 'POST') { + await requestRoute.fallback() + return + } + + requests.push( + requestSchema.parse(requestRoute.request().postDataJSON()) + ) + await requestRoute.fulfill({ json: response }) + } + ) + + return requests + } } export const jobsRouteFixture = base.extend<{ @@ -168,6 +213,5 @@ export const jobsRouteFixture = base.extend<{ }>({ jobsRoutes: async ({ page }, use) => { await use(new JobsRouteMocker(page)) - await page.unrouteAll({ behavior: 'wait' }) } }) diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 2745eff0b0..ef1f9e09d8 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -226,7 +226,10 @@ export const TestIds = { currentUserIndicator: 'current-user-indicator' }, queue: { + jobHistorySidebar: 'job-history-sidebar', + progressOverlay: 'queue-progress-overlay', overlayToggle: 'queue-overlay-toggle', + dockedJobHistoryAction: 'docked-job-history-action', jobDetailsPopover: 'queue-job-details-popover', clearHistoryAction: 'clear-history-action', jobAssetsList: 'job-assets-list', diff --git a/browser_tests/tests/bottomPanelLogs.spec.ts b/browser_tests/tests/bottomPanelLogs.spec.ts index 4befe18c67..bbc41ad334 100644 --- a/browser_tests/tests/bottomPanelLogs.spec.ts +++ b/browser_tests/tests/bottomPanelLogs.spec.ts @@ -147,5 +147,68 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => { ) await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden() }) + + test('resyncs the terminal when the WebSocket reconnects', async ({ + comfyPage, + logsTerminal, + getWebSocket + }) => { + const subscribeFetches = await logsTerminal.mockSubscribeLogs() + const initialLine = 'pre-reboot log line' + const postRebootLineA = 'post-reboot line A' + const postRebootLineB = 'post-reboot line B' + + await logsTerminal.mockRawLogs([initialLine]) + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + initialLine + ) + + // Swap the raw-logs mock so the next fetch returns the post-reboot view. + await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB]) + + const ws = await getWebSocket() + await logsTerminal.triggerReconnect(ws, subscribeFetches) + + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + postRebootLineA + ) + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + postRebootLineB + ) + // reset() before write means the pre-reboot line must be gone. + await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText( + initialLine + ) + }) + + test('resumes WebSocket log streaming after the reconnect', async ({ + comfyPage, + logsTerminal, + getWebSocket + }) => { + const subscribeFetches = await logsTerminal.mockSubscribeLogs() + await logsTerminal.mockRawLogs(['initial']) + await comfyPage.bottomPanel.toggleLogs() + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + 'initial' + ) + + await logsTerminal.mockRawLogs(['after-reboot snapshot']) + + const ws = await getWebSocket() + await logsTerminal.triggerReconnect(ws, subscribeFetches) + + // The route handler fires again on the new connection; pull the latest + // WebSocketRoute and push a live frame to prove the 'logs' listener + // survived the reconnect. + const liveLine = 'live log emitted after the reconnect' + const newWs = await getWebSocket() + newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine])) + + await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText( + liveLine + ) + }) }) }) diff --git a/browser_tests/tests/sidebar/jobHistory.spec.ts b/browser_tests/tests/sidebar/jobHistory.spec.ts new file mode 100644 index 0000000000..40154f9490 --- /dev/null +++ b/browser_tests/tests/sidebar/jobHistory.spec.ts @@ -0,0 +1,280 @@ +import { expect, mergeTests } from '@playwright/test' + +import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { + createRouteMockJob, + jobsRouteFixture, + routeMockJobTimestamp +} from '@e2e/fixtures/jobsRouteFixture' +import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture' +import { TestIds } from '@e2e/fixtures/selectors' +import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' + +const test = mergeTests(comfyPageFixture, jobsRouteFixture) + +const historyJobs: RawJobListItem[] = [ + createRouteMockJob({ + id: 'history-completed', + status: 'completed', + create_time: routeMockJobTimestamp - 60_000, + execution_start_time: routeMockJobTimestamp - 60_000, + execution_end_time: routeMockJobTimestamp - 55_000, + preview_output: { + filename: 'completed-output.png', + subfolder: '', + type: 'output', + nodeId: '1', + mediaType: 'images' + } + }), + createRouteMockJob({ + id: 'history-failed', + status: 'failed', + create_time: routeMockJobTimestamp - 120_000, + execution_start_time: routeMockJobTimestamp - 120_000, + execution_end_time: routeMockJobTimestamp - 118_000, + outputs_count: 0, + execution_error: { + node_id: '1', + node_type: 'SaveImage', + exception_message: 'Intentional fixture failure', + exception_type: 'Error', + traceback: [], + current_inputs: {}, + current_outputs: {} + } + }), + createRouteMockJob({ + id: 'history-cancelled', + status: 'cancelled', + create_time: routeMockJobTimestamp - 180_000, + execution_start_time: routeMockJobTimestamp - 180_000, + execution_end_time: routeMockJobTimestamp - 179_000, + outputs_count: 0 + }) +] + +const activeJobs: RawJobListItem[] = [ + createRouteMockJob({ + id: 'queue-running', + status: 'in_progress', + create_time: routeMockJobTimestamp - 10_000, + execution_start_time: routeMockJobTimestamp - 9_000, + execution_end_time: null, + outputs_count: 0 + }), + createRouteMockJob({ + id: 'queue-pending', + status: 'pending', + create_time: routeMockJobTimestamp - 5_000, + execution_start_time: null, + execution_end_time: null, + outputs_count: 0 + }) +] +const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending') + +async function setupJobHistorySidebar( + comfyPage: ComfyPage, + jobsRoutes: JobsRouteMocker, + { + history = historyJobs, + queue = activeJobs + }: { + history?: readonly RawJobListItem[] + queue?: readonly RawJobListItem[] + } = {} +) { + await jobsRoutes.mockJobsScenario({ history, queue }) + await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true) + await comfyPage.setup() + + await comfyPage.page + .getByTestId(TestIds.sidebar.toolbar) + .getByRole('button', { name: 'Job History', exact: true }) + .click() + await expect(jobHistorySidebar(comfyPage)).toBeVisible() +} + +function jobRow(comfyPage: ComfyPage) { + const list = comfyPage.page.getByTestId(TestIds.queue.jobAssetsList) + + return (jobId: string) => list.locator(`[data-job-id="${jobId}"]`) +} + +function jobHistorySidebar(comfyPage: ComfyPage) { + return comfyPage.page.getByTestId(TestIds.queue.jobHistorySidebar) +} + +function clearQueueButton(comfyPage: ComfyPage) { + return jobHistorySidebar(comfyPage).getByRole('button', { + name: 'Clear queue', + exact: true + }) +} + +async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) { + await jobHistorySidebar(comfyPage) + .getByLabel(/More options/i) + .click() + await comfyPage.page.getByTestId(TestIds.queue.clearHistoryAction).click() +} + +test.describe('Job history sidebar', { tag: '@ui' }, () => { + test('opens from the queue overlay docked history action', async ({ + comfyPage, + jobsRoutes + }) => { + await jobsRoutes.mockJobsScenario({ + history: historyJobs, + queue: activeJobs + }) + await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false) + await comfyPage.setup() + + await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click() + await comfyPage.queuePanel.moreOptionsButton.click() + await comfyPage.page + .getByTestId(TestIds.queue.dockedJobHistoryAction) + .click() + + await expect(jobHistorySidebar(comfyPage)).toBeVisible() + await expect(jobRow(comfyPage)('history-completed')).toBeVisible() + await expect(jobRow(comfyPage)('queue-pending')).toBeVisible() + }) + + test('shows terminal and active job states', async ({ + comfyPage, + jobsRoutes + }) => { + await setupJobHistorySidebar(comfyPage, jobsRoutes) + + const row = jobRow(comfyPage) + await expect(row('queue-pending')).toBeVisible() + await expect(row('queue-running')).toBeVisible() + await expect(row('history-completed')).toBeVisible() + await expect(row('history-failed')).toBeVisible() + await expect(row('history-cancelled')).toBeVisible() + + await expect(clearQueueButton(comfyPage)).toBeEnabled() + }) + + test('filters completed and failed history jobs', async ({ + comfyPage, + jobsRoutes + }) => { + await setupJobHistorySidebar(comfyPage, jobsRoutes) + + await comfyPage.page + .getByRole('button', { name: 'Completed', exact: true }) + .click() + + const row = jobRow(comfyPage) + await expect(row('history-completed')).toBeVisible() + await expect(row('history-failed')).toBeHidden() + await expect(row('queue-running')).toBeHidden() + + await comfyPage.page + .getByRole('button', { name: 'Failed', exact: true }) + .click() + + await expect(row('history-failed')).toBeVisible() + await expect(row('history-cancelled')).toBeVisible() + await expect(row('history-completed')).toBeHidden() + }) + + test('searches by job id and output filename', async ({ + comfyPage, + jobsRoutes + }) => { + await setupJobHistorySidebar(comfyPage, jobsRoutes) + + const row = jobRow(comfyPage) + const searchInput = comfyPage.page.getByPlaceholder('Search...') + + await searchInput.fill('history-failed') + await expect(row('history-failed')).toBeVisible() + await expect(row('history-completed')).toBeHidden() + await expect(row('queue-running')).toBeHidden() + + await searchInput.fill('completed-output') + await expect(row('history-completed')).toBeVisible() + await expect(row('history-failed')).toBeHidden() + + await searchInput.clear() + await expect(row('history-completed')).toBeVisible() + await expect(row('queue-running')).toBeVisible() + }) + + test('disables clear queue when there are no pending jobs', async ({ + comfyPage, + jobsRoutes + }) => { + await setupJobHistorySidebar(comfyPage, jobsRoutes, { + queue: runningOnlyJobs + }) + + await expect(clearQueueButton(comfyPage)).toBeDisabled() + }) + + test('clears pending queue jobs and leaves running/history jobs', async ({ + comfyPage, + jobsRoutes + }) => { + await setupJobHistorySidebar(comfyPage, jobsRoutes) + + const row = jobRow(comfyPage) + await expect(row('queue-pending')).toBeVisible() + + const clearQueueRequests = await jobsRoutes.mockClearQueue() + const clearHistoryRequests = await jobsRoutes.mockClearHistory() + await jobsRoutes.mockJobsScenario({ + history: historyJobs, + queue: runningOnlyJobs + }) + + await clearQueueButton(comfyPage).click() + + await expect.poll(() => clearQueueRequests.length).toBe(1) + expect(clearQueueRequests).toContainEqual({ clear: true }) + await expect(row('queue-pending')).toBeHidden() + await expect(row('queue-running')).toBeVisible() + await expect(row('history-completed')).toBeVisible() + await expect(clearQueueButton(comfyPage)).toBeDisabled() + expect(clearHistoryRequests).toHaveLength(0) + }) + + test('clears history from the sidebar menu and keeps active jobs', async ({ + comfyPage, + jobsRoutes + }) => { + await setupJobHistorySidebar(comfyPage, jobsRoutes) + + const row = jobRow(comfyPage) + await expect(row('history-completed')).toBeVisible() + + const clearHistoryRequests = await jobsRoutes.mockClearHistory() + const clearQueueRequests = await jobsRoutes.mockClearQueue() + await jobsRoutes.mockJobsScenario({ + history: [], + queue: activeJobs + }) + + await openSidebarClearHistoryDialog(comfyPage) + await expect( + comfyPage.page.getByText('Clear your job queue history?') + ).toBeVisible() + await comfyPage.page + .getByRole('button', { name: 'Clear', exact: true }) + .click() + + await expect.poll(() => clearHistoryRequests.length).toBe(1) + expect(clearHistoryRequests).toContainEqual({ clear: true }) + await expect(row('history-completed')).toBeHidden() + await expect(row('history-failed')).toBeHidden() + await expect(row('queue-running')).toBeVisible() + await expect(row('queue-pending')).toBeVisible() + expect(clearQueueRequests).toHaveLength(0) + }) +}) diff --git a/src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts b/src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts new file mode 100644 index 0000000000..ba4707ce14 --- /dev/null +++ b/src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts @@ -0,0 +1,291 @@ +import { createTestingPinia } from '@pinia/testing' +import { render, screen } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue' + +const apiMock = vi.hoisted( + () => + new (class extends EventTarget { + clientId: string | null = 'test-client' + getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] })) + subscribeLogs = vi.fn(async () => {}) + })() +) + +vi.mock('@/scripts/api', () => ({ api: apiMock })) + +const terminalMock = vi.hoisted(() => ({ + open: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + reset: vi.fn(), + scrollToBottom: vi.fn(), + onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), + getSelection: vi.fn(() => ''), + selectAll: vi.fn(), + clearSelection: vi.fn() +})) + +vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({ + useTerminal: vi.fn(() => ({ + terminal: terminalMock, + useAutoSize: vi.fn(() => ({ resize: vi.fn() })) + })) +})) + +vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => { + const { defineComponent, ref } = await import('vue') + const { useTerminal } = + await import('@/composables/bottomPanelTabs/useTerminal') + return { + default: defineComponent({ + emits: ['created'], + setup(_, { emit }) { + const root = ref(undefined) + emit('created', useTerminal(root), root) + return () => null + } + }) + } +}) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + logsTerminal: { + loadError: + 'Unable to load logs, please ensure you have updated your ComfyUI backend.', + resyncError: + 'Unable to resync logs after the backend reconnected. Reopen the console to retry.' + } + } + } +}) + +const renderLogsTerminal = () => + render(LogsTerminal, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false, + initialState: { execution: { clientId: 'test-client' } } + }), + i18n + ] + } + }) + +// Silence the production console.error calls in error-path tests. Vitest +// isolates this file's module graph so the spy does not affect other files. +vi.spyOn(console, 'error').mockImplementation(() => {}) + +// Resolve a getRawLogs call manually to drive deterministic timing in tests +// that need to observe behavior mid-fetch. +const deferredRawLogs = () => { + let resolve!: (value: { entries: { m: string }[] }) => void + let reject!: (err: unknown) => void + const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('LogsTerminal', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMock.clientId = 'test-client' + }) + + it('loads logs and subscribes to streaming on mount', async () => { + renderLogsTerminal() + + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + expect(terminalMock.write).toHaveBeenCalledWith('log line\n') + }) + }) + + it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => { + renderLogsTerminal() + + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2) + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1) + expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2) + expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true) + }) + + // The full sequence must be: reset -> write -> scroll -> subscribe + const resetOrder = terminalMock.reset.mock.invocationCallOrder[0] + const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)! + const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0] + const subscribeOrder = + apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)! + expect(resetOrder).toBeLessThan(writeOrder) + expect(writeOrder).toBeLessThan(scrollOrder) + expect(scrollOrder).toBeLessThan(subscribeOrder) + }) + + it('aborts an in-flight resync when a second "reconnected" arrives', async () => { + renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + // First resync hangs on getRawLogs + const first = deferredRawLogs() + apiMock.getRawLogs.mockImplementationOnce(() => first.promise) + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2) + }) + + // Second resync resolves immediately + apiMock.getRawLogs.mockImplementationOnce(async () => ({ + entries: [{ m: 'fresh\n' }] + })) + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await vi.waitFor(() => { + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + }) + + // Now resolve the first (aborted) resync — none of its side effects must apply + first.resolve({ entries: [{ m: 'stale\n' }] }) + await nextTick() + await nextTick() + + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n') + expect(terminalMock.write).toHaveBeenCalledWith('fresh\n') + }) + + it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => { + // Mount's getRawLogs hangs so we can drive the race deterministically. + const mount = deferredRawLogs() + apiMock.getRawLogs.mockImplementationOnce(() => mount.promise) + renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + }) + + // Resync wins the race and writes the post-reboot snapshot. + apiMock.getRawLogs.mockImplementationOnce(async () => ({ + entries: [{ m: 'fresh\n' }] + })) + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await vi.waitFor(() => { + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + expect(terminalMock.write).toHaveBeenCalledWith('fresh\n') + }) + + // Mount's late response must not stomp on the freshly-reset terminal. + mount.resolve({ entries: [{ m: 'stale-mount\n' }] }) + await nextTick() + await nextTick() + + expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n') + }) + + it('surfaces an inline error when the resync fetch fails', async () => { + renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom')) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + + await vi.waitFor(() => { + expect( + screen.getByTestId('terminal-error-message').textContent + ).toContain('Unable to resync logs') + }) + }) + + it('shows a load error when the initial fetch fails', async () => { + apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom')) + + renderLogsTerminal() + + await vi.waitFor(() => { + expect( + screen.getByTestId('terminal-error-message').textContent + ).toContain('Unable to load logs') + }) + }) + + it('recovers from an initial load failure when a reconnect arrives', async () => { + apiMock.getRawLogs + .mockRejectedValueOnce(new Error('initial fail')) + .mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] }) + + renderLogsTerminal() + + await vi.waitFor(() => { + expect( + screen.getByTestId('terminal-error-message').textContent + ).toContain('Unable to load logs') + }) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + + await vi.waitFor(() => { + expect(screen.queryByTestId('terminal-error-message')).toBeNull() + expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull() + expect(terminalMock.write).toHaveBeenCalledWith('recovered\n') + }) + }) + + it('cleans up listeners and unsubscribes on unmount', async () => { + const { unmount } = renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + unmount() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false) + }) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await nextTick() + + expect(terminalMock.reset).not.toHaveBeenCalled() + // No additional getRawLogs beyond the mount-time call + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + }) + + it('does not write to the terminal when unmount happens mid-fetch', async () => { + const pending = deferredRawLogs() + apiMock.getRawLogs.mockImplementationOnce(() => pending.promise) + + const { unmount } = renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + }) + + unmount() + pending.resolve({ entries: [{ m: 'late\n' }] }) + await nextTick() + await nextTick() + + expect(terminalMock.write).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue b/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue index bfb485217f..4360888c43 100644 --- a/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue @@ -12,79 +12,36 @@ data-testid="terminal-loading-spinner" class="relative inset-0 z-10 flex h-full items-center justify-center" /> - + diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index 9ba1dea9f9..38cfae3146 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -4,6 +4,7 @@ :class="['flex', 'justify-end', 'w-full', 'pointer-events-none']" >
{ const toast = useToast() -const inputAssets = useMediaAssets('input') -const outputAssets = useMediaAssets('output') +const inputAssets = useAssetsApi('input') +const outputAssets = useAssetsApi('output') // Asset selection const { diff --git a/src/components/sidebar/tabs/JobHistorySidebarTab.vue b/src/components/sidebar/tabs/JobHistorySidebarTab.vue index b36b9ed428..678e1c23aa 100644 --- a/src/components/sidebar/tabs/JobHistorySidebarTab.vue +++ b/src/components/sidebar/tabs/JobHistorySidebarTab.vue @@ -1,5 +1,8 @@