mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Merge branch 'main' into drjkl/subgraph-promoted-widget-ratchet-squashed
This commit is contained in:
14
apps/website/public/favicon.svg
Normal file
14
apps/website/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -71,7 +71,7 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<void>((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<void> {
|
||||
const before = subscribeFetches()
|
||||
await ws.close()
|
||||
await expect.poll(subscribeFetches).toBeGreaterThan(before)
|
||||
}
|
||||
|
||||
static buildWsLogFrame(messages: string[]): string {
|
||||
|
||||
@@ -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<typeof zJobsListResponse>
|
||||
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
|
||||
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
|
||||
|
||||
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<QueueManageRequest[]> {
|
||||
const response = zQueueManageResponse.parse({ cleared: true })
|
||||
return await this.mockPostManageRoute(
|
||||
'queue',
|
||||
zQueueManageRequest,
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
async mockClearHistory(): Promise<HistoryManageRequest[]> {
|
||||
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
|
||||
}
|
||||
|
||||
private async mockPostManageRoute<TRequest>(
|
||||
type: 'queue' | 'history',
|
||||
requestSchema: z.ZodType<TRequest>,
|
||||
response: unknown
|
||||
): Promise<TRequest[]> {
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
280
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
280
browser_tests/tests/sidebar/jobHistory.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
291
src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts
Normal file
291
src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts
Normal file
@@ -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<HTMLElement | undefined>(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()
|
||||
})
|
||||
})
|
||||
@@ -12,79 +12,36 @@
|
||||
data-testid="terminal-loading-spinner"
|
||||
class="relative inset-0 z-10 flex h-full items-center justify-center"
|
||||
/>
|
||||
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||
<BaseTerminal
|
||||
v-show="!loading && !errorMessage"
|
||||
@created="terminalCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useLogsTerminal } from '@/composables/bottomPanelTabs/useLogsTerminal'
|
||||
|
||||
import BaseTerminal from './BaseTerminal.vue'
|
||||
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
const terminal = shallowRef<Terminal>()
|
||||
const { errorMessage, loading } = useLogsTerminal(terminal)
|
||||
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
{ terminal: instance, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) => {
|
||||
// Auto-size terminal to fill container width.
|
||||
// minCols: 80 ensures minimum width for colab environments.
|
||||
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
|
||||
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
|
||||
|
||||
const update = (entries: Array<LogEntry>) => {
|
||||
terminal.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
|
||||
update(e.detail.entries)
|
||||
}
|
||||
|
||||
const loadLogEntries = async () => {
|
||||
const logs = await api.getRawLogs()
|
||||
update(logs.entries)
|
||||
}
|
||||
|
||||
const watchLogs = async () => {
|
||||
const { clientId } = storeToRefs(useExecutionStore())
|
||||
if (!clientId.value) {
|
||||
await until(clientId).not.toBeNull()
|
||||
}
|
||||
await api.subscribeLogs(true)
|
||||
api.addEventListener('logs', logReceived)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadLogEntries()
|
||||
} catch (err) {
|
||||
console.error('Error loading logs', err)
|
||||
// On older backends the endpoints won't exist
|
||||
errorMessage.value =
|
||||
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
|
||||
return
|
||||
}
|
||||
|
||||
await watchLogs()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
if (api.clientId) {
|
||||
await api.subscribeLogs(false)
|
||||
}
|
||||
api.removeEventListener('logs', logReceived)
|
||||
})
|
||||
terminal.value = instance
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
|
||||
>
|
||||
<div
|
||||
data-testid="queue-progress-overlay"
|
||||
class="pointer-events-auto flex max-h-[60vh] w-[350px] min-w-[310px] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
|
||||
:class="containerClass"
|
||||
@mouseenter="isHovered = true"
|
||||
|
||||
@@ -237,7 +237,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
@@ -309,8 +309,8 @@ const formattedExecutionTime = computed(() => {
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
const outputAssets = useMediaAssets('output')
|
||||
const inputAssets = useAssetsApi('input')
|
||||
const outputAssets = useAssetsApi('output')
|
||||
|
||||
// Asset selection
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('queue.jobHistory')">
|
||||
<SidebarTabTemplate
|
||||
data-testid="job-history-sidebar"
|
||||
:title="$t('queue.jobHistory')"
|
||||
>
|
||||
<template #alt-title>
|
||||
<div class="ml-auto flex shrink-0 items-center">
|
||||
<JobHistoryActionsMenu @clear-history="onClearHistory" />
|
||||
|
||||
123
src/composables/bottomPanelTabs/useLogsTerminal.ts
Normal file
123
src/composables/bottomPanelTabs/useLogsTerminal.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { until, useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onScopeDispose, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
type TerminalLike = {
|
||||
write: (data: string) => void
|
||||
reset: () => void
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the built-in logs terminal: initial load, live `logs` stream, and
|
||||
* full resync when the backend WebSocket reconnects (e.g., after a reboot).
|
||||
*
|
||||
* Listeners are registered synchronously so we cannot miss a `reconnected`
|
||||
* event during the mount-time fetch/subscribe awaits. In-flight fetches are
|
||||
* tied to AbortControllers so that:
|
||||
* - rapid double-reconnects don't interleave writes / double-subscribe
|
||||
* - unmount mid-fetch never writes to a disposed terminal
|
||||
*/
|
||||
export function useLogsTerminal(
|
||||
terminal: Readonly<Ref<TerminalLike | undefined>>
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
let mountController: AbortController | undefined
|
||||
let resyncController: AbortController | undefined
|
||||
|
||||
const writeEntries = (entries: LogEntry[]) => {
|
||||
terminal.value?.write(entries.map((e) => e.m).join(''))
|
||||
}
|
||||
|
||||
const resyncLogs = async () => {
|
||||
// Cancel both the in-flight mount fetch and any prior resync so a late
|
||||
// mount response can't write a stale snapshot on top of a freshly-reset
|
||||
// terminal after we've already written the post-reconnect view.
|
||||
mountController?.abort()
|
||||
resyncController?.abort()
|
||||
const controller = new AbortController()
|
||||
resyncController = controller
|
||||
const { signal } = controller
|
||||
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
if (signal.aborted || !terminal.value) return
|
||||
terminal.value.reset()
|
||||
writeEntries(logs.entries)
|
||||
terminal.value.scrollToBottom()
|
||||
// Backend lost the per-client log subscription across the restart;
|
||||
// re-subscribe so new runtime logs stream over the fresh WebSocket.
|
||||
await api.subscribeLogs(true)
|
||||
if (signal.aborted) return
|
||||
errorMessage.value = ''
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error resyncing logs after reconnect', err)
|
||||
errorMessage.value = t('logsTerminal.resyncError')
|
||||
}
|
||||
}
|
||||
|
||||
// Register listeners synchronously, before any awaits, so a reconnect
|
||||
// fired during mount cannot be missed. useEventListener handles cleanup
|
||||
// on scope dispose.
|
||||
useEventListener(api, 'logs', (e: CustomEvent<LogsWsMessage>) => {
|
||||
writeEntries(e.detail.entries)
|
||||
})
|
||||
useEventListener(api, 'reconnected', () => {
|
||||
void resyncLogs()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!terminal.value) await until(terminal).toBeTruthy()
|
||||
|
||||
const controller = new AbortController()
|
||||
mountController = controller
|
||||
const { signal } = controller
|
||||
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
if (signal.aborted || !terminal.value) return
|
||||
writeEntries(logs.entries)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error loading logs', err)
|
||||
errorMessage.value = t('logsTerminal.loadError')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const { clientId } = storeToRefs(useExecutionStore())
|
||||
if (!clientId.value) await until(clientId).not.toBeNull()
|
||||
if (signal.aborted) return
|
||||
|
||||
try {
|
||||
await api.subscribeLogs(true)
|
||||
} catch (err) {
|
||||
if (signal.aborted) return
|
||||
console.error('Error subscribing to logs', err)
|
||||
}
|
||||
|
||||
if (!signal.aborted) loading.value = false
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
mountController?.abort()
|
||||
resyncController?.abort()
|
||||
if (!api.clientId) return
|
||||
api.subscribeLogs(false).catch((err) => {
|
||||
console.error('Error unsubscribing from logs', err)
|
||||
})
|
||||
})
|
||||
|
||||
return { errorMessage, loading }
|
||||
}
|
||||
@@ -1160,6 +1160,10 @@
|
||||
"saveAsTemplate": "Save as template",
|
||||
"enterName": "Enter name"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"workflowService": {
|
||||
"exportWorkflow": "Export Workflow",
|
||||
"enterFilename": "Enter the filename",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
/**
|
||||
* Composable for fetching media assets from local environment
|
||||
* Uses AssetsStore for centralized state management
|
||||
*/
|
||||
export function useInternalFilesApi(directory: 'input' | 'output') {
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
const media = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
|
||||
)
|
||||
|
||||
const loading = computed(() =>
|
||||
directory === 'input'
|
||||
? assetsStore.inputLoading
|
||||
: assetsStore.historyLoading
|
||||
)
|
||||
|
||||
const error = computed(() =>
|
||||
directory === 'input' ? assetsStore.inputError : assetsStore.historyError
|
||||
)
|
||||
|
||||
const fetchMediaList = async (): Promise<AssetItem[]> => {
|
||||
if (directory === 'input') {
|
||||
await assetsStore.updateInputs()
|
||||
return assetsStore.inputAssets
|
||||
} else {
|
||||
await assetsStore.updateHistory()
|
||||
return assetsStore.historyAssets
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => fetchMediaList()
|
||||
|
||||
const loadMore = async (): Promise<void> => {
|
||||
if (directory === 'output') {
|
||||
await assetsStore.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.hasMoreHistory : false
|
||||
})
|
||||
|
||||
const isLoadingMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.isLoadingMore : false
|
||||
})
|
||||
|
||||
return {
|
||||
media,
|
||||
loading,
|
||||
error,
|
||||
fetchMediaList,
|
||||
refresh,
|
||||
loadMore,
|
||||
hasMore,
|
||||
isLoadingMore
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import type { IAssetsProvider } from './IAssetsProvider'
|
||||
import { useAssetsApi } from './useAssetsApi'
|
||||
import { useInternalFilesApi } from './useInternalFilesApi'
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate media assets implementation
|
||||
* based on the current distribution (cloud vs internal)
|
||||
* @param directory - The directory to fetch assets from
|
||||
* @returns IAssetsProvider implementation
|
||||
*/
|
||||
export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
|
||||
return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
|
||||
}
|
||||
@@ -23,8 +23,8 @@ const selectAsLatestFn = vi.fn()
|
||||
const resolveIfReadyFn = vi.fn()
|
||||
const resolvedOutputsCacheRef = new Map<string, ResultItemImpl[]>()
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => ({
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => ({
|
||||
media: mediaRef,
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ComputedRef } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -25,7 +25,7 @@ export function useOutputHistory(): {
|
||||
isWorkflowActive: ComputedRef<boolean>
|
||||
cancelActiveWorkflowJobs: () => Promise<void>
|
||||
} {
|
||||
const backingOutputs = useMediaAssets('output')
|
||||
const backingOutputs = useAssetsApi('output')
|
||||
void backingOutputs.fetchMediaList()
|
||||
const linearStore = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -69,8 +69,8 @@ const { mockMediaAssets } = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
@@ -47,7 +47,7 @@ const modelValue = defineModel<string | undefined>({
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
const outputMediaAssets = useAssetsApi('output')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ function createMockMediaAssets() {
|
||||
|
||||
let mockMediaAssets = createMockMediaAssets()
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: () => mockMediaAssets
|
||||
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
|
||||
useAssetsApi: () => mockMediaAssets
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
@@ -65,7 +65,7 @@ interface UseWidgetSelectItemsOptions {
|
||||
>
|
||||
modelValue: Ref<string | undefined>
|
||||
assetKind: MaybeRefOrGetter<AssetKind | undefined>
|
||||
outputMediaAssets: ReturnType<typeof useMediaAssets>
|
||||
outputMediaAssets: IAssetsProvider
|
||||
assetData: ReturnType<typeof useAssetWidgetData> | null
|
||||
isAssetMode: MaybeRefOrGetter<boolean | undefined>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user