mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
## Summary When the built-in logs terminal stayed open during a backend restart, the buffer froze on pre-restart entries and live log streaming silently stopped — only closing and reopening the panel resynced. Listen for the api `reconnected` event and rebuild the terminal contents the same way a fresh open would. ## Changes - **What**: - Extract `useLogsTerminal` composable. The SFC is now a thin shell holding `terminal: shallowRef<Terminal>` and forwarding to the composable, so `onMounted`/`onScopeDispose` no longer rely on the child's emit callback timing. - Subscribe to `api`'s `reconnected` event via `useEventListener`, registered synchronously before any awaits. On reconnect: `terminal.reset()` → refetch raw logs → `scrollToBottom()` → `subscribeLogs(true)` (the backend loses the per-client subscription on restart, so re-subscribe is required for live streaming to resume). - Wrap in-flight resync/mount fetches in AbortControllers. Overlapping reconnects abort the prior resync, and unmount mid-fetch suppresses writes to the disposed xterm. - Hide BaseTerminal whenever `errorMessage` is set so the error layout doesn't expose an empty xterm container behind the message; `loading=false` after both load failure and resync success so a later successful reconnect can clear a stuck spinner. - Migrate the load/resync error strings to vue-i18n (`logsTerminal.loadError`, `logsTerminal.resyncError`). ## Review Focus - **Re-subscribe is the non-obvious half of the fix** — without it, even after the WebSocket reconnects the backend never resumes streaming logs to this client because its subscription state was wiped on restart. The visible "stale buffer" is only one symptom; the silent "no new logs" symptom needed the explicit `subscribeLogs(true)` re-call in resync. - `terminal.reset()` lives after a successful raw-logs fetch (not before) so a failed resync leaves the prior buffer visible instead of blanking it; resync errors surface via the same inline error message the mount path uses. - 8 unit tests around the composable: mount + subscribe, resync ordering (reset → write → scroll → subscribe via `invocationCallOrder`), in-flight resync abort on double reconnect, resync error surfacing, mount-failure-then-recovery, unmount-mid-fetch terminal-write suppression, listener cleanup on unmount. - 2 E2E tests using `ws.close()` on the proxied WebSocket as the reconnect trigger and `subscribeLogs` HTTP fetch count as the sync point (same pattern as `wsReconnectStaleJob.spec.ts`). Red-checked: disabling the `reconnected` listener fails exactly the two new tests, all 8 pre-existing tests stay green. Fixes FE-712 ## Screenshots Before - (After rebooting, the console window does not update from its state before the reboot must remount the console window for it to resync.) https://github.com/user-attachments/assets/b1e49c2c-89a4-4a4a-82b4-064412acee12 After - (The console window syncs automatically after a reboot.) https://github.com/user-attachments/assets/54b582c5-ad42-41c0-9886-18f4495859da ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12270-fix-terminal-resync-logs-console-on-backend-reconnect-3606d73d3650812fb13fd1934c632344) by [Unito](https://www.unito.io)
215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
import { mergeTests } from '@playwright/test'
|
|
|
|
import {
|
|
comfyExpect as expect,
|
|
comfyPageFixture
|
|
} from '@e2e/fixtures/ComfyPage'
|
|
import {
|
|
LogsTerminalHelper,
|
|
logsTerminalFixture
|
|
} from '@e2e/fixtures/helpers/LogsTerminalHelper'
|
|
import { webSocketFixture } from '@e2e/fixtures/ws'
|
|
import {
|
|
getClipboardText,
|
|
interceptClipboardWrite
|
|
} from '@e2e/fixtures/utils/clipboardSpy'
|
|
|
|
const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture)
|
|
|
|
test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
|
|
test.describe('panel', () => {
|
|
test.beforeEach(async ({ logsTerminal }) => {
|
|
await logsTerminal.mockSubscribeLogs()
|
|
await logsTerminal.mockRawLogs([])
|
|
})
|
|
|
|
test('opens to Logs tab via toggle button', async ({ comfyPage }) => {
|
|
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
await expect(comfyPage.bottomPanel.logs.tab).toHaveAttribute(
|
|
'aria-selected',
|
|
'true'
|
|
)
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
|
|
})
|
|
|
|
test('closes via toggle button', async ({ comfyPage }) => {
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
await expect(comfyPage.bottomPanel.root).toBeVisible()
|
|
|
|
await comfyPage.bottomPanel.toggleButton.click()
|
|
await expect(comfyPage.bottomPanel.root).toBeHidden()
|
|
})
|
|
|
|
test('switches from shortcuts to Logs tab', async ({ comfyPage }) => {
|
|
await comfyPage.bottomPanel.keyboardShortcutsButton.click()
|
|
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeVisible()
|
|
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
await expect(comfyPage.bottomPanel.logs.tab).toBeVisible()
|
|
await expect(comfyPage.bottomPanel.shortcuts.essentialsTab).toBeHidden()
|
|
})
|
|
})
|
|
|
|
test.describe('terminal', () => {
|
|
test.beforeEach(async ({ logsTerminal }) => {
|
|
await logsTerminal.mockSubscribeLogs()
|
|
await logsTerminal.mockRawLogs([])
|
|
})
|
|
|
|
test('shows loading spinner while logs are loading', async ({
|
|
comfyPage,
|
|
logsTerminal
|
|
}) => {
|
|
const resolveRaw = await logsTerminal.mockRawLogsPending()
|
|
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeVisible()
|
|
|
|
resolveRaw()
|
|
await expect(comfyPage.bottomPanel.logs.loadingSpinner).toBeHidden()
|
|
})
|
|
|
|
test('renders initial log entries from the raw-logs API', async ({
|
|
comfyPage,
|
|
logsTerminal
|
|
}) => {
|
|
const logLine = 'Hello from ComfyUI backend!'
|
|
await logsTerminal.mockRawLogs([logLine])
|
|
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
|
|
await expect(comfyPage.bottomPanel.logs.xtermScreen).toBeVisible()
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
|
logLine
|
|
)
|
|
})
|
|
|
|
test('appends log entries received via WebSocket', async ({
|
|
comfyPage,
|
|
getWebSocket
|
|
}) => {
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeVisible()
|
|
|
|
const ws = await getWebSocket()
|
|
const firstLine = 'First live log line'
|
|
const secondLine = 'Second live log line'
|
|
|
|
ws.send(LogsTerminalHelper.buildWsLogFrame([firstLine]))
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
|
firstLine
|
|
)
|
|
|
|
ws.send(LogsTerminalHelper.buildWsLogFrame([secondLine]))
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
|
firstLine
|
|
)
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
|
secondLine
|
|
)
|
|
})
|
|
|
|
test('copy button copies terminal contents to clipboard', async ({
|
|
comfyPage,
|
|
logsTerminal
|
|
}) => {
|
|
const logLine = 'Copy me to the clipboard'
|
|
await logsTerminal.mockRawLogs([logLine])
|
|
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
|
|
logLine
|
|
)
|
|
|
|
await interceptClipboardWrite(comfyPage.page)
|
|
|
|
await comfyPage.bottomPanel.logs.terminalRoot.hover()
|
|
await expect(comfyPage.bottomPanel.logs.copyButton).toBeVisible()
|
|
await comfyPage.bottomPanel.logs.copyButton.click()
|
|
|
|
await expect
|
|
.poll(() => getClipboardText(comfyPage.page))
|
|
.toContain(logLine)
|
|
})
|
|
|
|
test('shows error message when raw-logs API fails', async ({
|
|
comfyPage,
|
|
logsTerminal
|
|
}) => {
|
|
await logsTerminal.mockRawLogsError()
|
|
|
|
await comfyPage.bottomPanel.toggleLogs()
|
|
|
|
await expect(comfyPage.bottomPanel.logs.errorMessage).toBeVisible()
|
|
await expect(comfyPage.bottomPanel.logs.errorMessage).toContainText(
|
|
'Unable to load logs'
|
|
)
|
|
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
|
|
)
|
|
})
|
|
})
|
|
})
|