Compare commits

...

11 Commits

Author SHA1 Message Date
pythongosssss
cac65ebb96 Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests
# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
#	browser_tests/tests/actionbar.spec.ts
2026-04-08 12:37:47 -07:00
pythongosssss
20a50817af Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests
# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
2026-04-08 10:45:54 -07:00
pythongosssss
d986dcab3a prevent hitting server 2026-04-08 05:50:03 -07:00
pythongosssss
ab4a00d6dc add new progress bar tests
tidy tests, remove unused/unnecessary code
2026-04-08 05:38:47 -07:00
pythongosssss
4492e3f2d6 replace manual page evaluate 2026-04-08 02:53:50 -07:00
pythongosssss
70b8e660bf Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests
# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
#	browser_tests/fixtures/selectors.ts
2026-04-08 02:48:43 -07:00
pythongosssss
fb8684e218 fix imports 2026-04-08 02:42:10 -07:00
pythongosssss
d8ac560229 fix bug with auto selection incorrectly over-following
improve tests
2026-04-07 13:28:29 -07:00
pythongosssss
e298998732 rabbit fixes 2026-04-07 13:02:44 -07:00
pythongosssss
ff3b43ac39 add test for scrolling outputs
refactor execution helper to use assets helper for mock jobs
2026-04-07 12:38:04 -07:00
pythongosssss
fd4782f37d add app mode execution tests
add execution helper
2026-04-07 12:38:04 -07:00
16 changed files with 805 additions and 58 deletions

View File

@@ -0,0 +1,97 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '../selectors'
const ids = TestIds.outputHistory
export class OutputHistoryComponent {
constructor(private readonly page: Page) {}
get outputs(): Locator {
return this.page.getByTestId(ids.outputs)
}
get welcome(): Locator {
return this.page.getByTestId(ids.welcome)
}
get outputInfo(): Locator {
return this.page.getByTestId(ids.outputInfo)
}
get activeQueue(): Locator {
return this.page.getByTestId(ids.activeQueue)
}
get queueBadge(): Locator {
return this.page.getByTestId(ids.queueBadge)
}
get inProgressItems(): Locator {
return this.page.getByTestId(ids.inProgressItem)
}
get historyItems(): Locator {
return this.page.getByTestId(ids.historyItem)
}
get skeletons(): Locator {
return this.page.getByTestId(ids.skeleton)
}
get latentPreviews(): Locator {
return this.page.getByTestId(ids.latentPreview)
}
get imageOutputs(): Locator {
return this.page.getByTestId(ids.imageOutput)
}
get videoOutputs(): Locator {
return this.page.getByTestId(ids.videoOutput)
}
/** The currently selected (checked) in-progress item. */
get selectedInProgressItem(): Locator {
return this.page.locator(
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
)
}
/** The currently selected (checked) history item. */
get selectedHistoryItem(): Locator {
return this.page.locator(
`[data-testid="${ids.historyItem}"][data-state="checked"]`
)
}
/** The header-level progress bar. */
get headerProgressBar(): Locator {
return this.page.getByTestId(ids.headerProgressBar)
}
/** The in-progress item's progress bar (inside the thumbnail). */
get itemProgressBar(): Locator {
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
}
/** Overall progress in the header bar. */
get headerOverallProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the header bar. */
get headerNodeProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressNode)
}
/** Overall progress in the in-progress item bar. */
get itemOverallProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the in-progress item bar. */
get itemNodeProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressNode)
}
}

View File

@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -14,6 +15,7 @@ export class AppModeHelper {
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly widgets: AppModeWidgetHelper
constructor(private readonly comfyPage: ComfyPage) {
@@ -21,6 +23,7 @@ export class AppModeHelper {
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.widgets = new AppModeWidgetHelper(comfyPage)
}
@@ -93,6 +96,10 @@ export class AppModeHelper {
await this.toggleAppMode()
}
get cancelRunButton(): Locator {
return this.page.getByTestId(TestIds.outputHistory.cancelRun)
}
/** The "Connect an output" popover shown when saving without outputs. */
get connectOutputPopover(): Locator {
return this.page.getByTestId(TestIds.builder.connectOutputPopover)

View File

@@ -0,0 +1,211 @@
import type { WebSocketRoute } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '../ComfyPage'
import { createMockJob } from './AssetsHelper'
/**
* Helper for simulating prompt execution in e2e tests.
*/
export class ExecutionHelper {
private jobCounter = 0
private readonly completedJobs: RawJobListItem[] = []
private readonly page: ComfyPage['page']
private readonly command: ComfyPage['command']
private readonly assets: ComfyPage['assets']
constructor(
comfyPage: ComfyPage,
private readonly ws: WebSocketRoute
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
}
/**
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
* the synthetic job ID.
*
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
fulfilled = r
})
await this.page.route(
'**/api/prompt',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
})
})
fulfilled()
},
{ times: 1 }
)
await this.command.executeCommand('Comfy.QueuePrompt')
await prompted
return jobId
}
/**
* Send a binary `b_preview_with_metadata` WS message (type 4).
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
*/
latentPreview(jobId: string, nodeId: string): void {
const metadata = JSON.stringify({
node_id: nodeId,
display_node_id: nodeId,
parent_node_id: nodeId,
real_node_id: nodeId,
prompt_id: jobId,
image_type: 'image/png'
})
const metadataBytes = new TextEncoder().encode(metadata)
// 1x1 red PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
'base64'
)
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
const view = new DataView(buf)
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
view.setUint32(4, metadataBytes.length)
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.ws.send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.ws.send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
})
)
}
/** Send `executed` WS event with node output. */
executed(
jobId: string,
nodeId: string,
output: Record<string, unknown>
): void {
this.ws.send(
JSON.stringify({
type: 'executed',
data: {
prompt_id: jobId,
node: nodeId,
display_node: nodeId,
output
}
})
)
}
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_error',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
exception_message: message,
exception_type: 'RuntimeError',
traceback: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.ws.send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
*
* Requires an {@link AssetsHelper} to be passed in the constructor.
*/
async completeWithHistory(
jobId: string,
nodeId: string,
filename: string
): Promise<void> {
this.completedJobs.push(
createMockJob({
id: jobId,
preview_output: {
filename,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
})
)
await this.assets.mockOutputHistory(this.completedJobs)
this.executionSuccess(jobId)
// Trigger queue/history refresh
this.status(0)
}
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.ws.send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
})
)
}
}

View File

@@ -129,6 +129,24 @@ export const TestIds = {
outputPlaceholder: 'builder-output-placeholder',
connectOutputPopover: 'builder-connect-output-popover'
},
outputHistory: {
outputs: 'linear-outputs',
welcome: 'linear-welcome',
outputInfo: 'linear-output-info',
activeQueue: 'linear-job',
queueBadge: 'linear-job-badge',
inProgressItem: 'linear-in-progress-item',
historyItem: 'linear-history-item',
skeleton: 'linear-skeleton',
latentPreview: 'linear-latent-preview',
imageOutput: 'linear-image-output',
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run',
headerProgressBar: 'linear-header-progress-bar',
itemProgressBar: 'linear-item-progress-bar',
progressOverall: 'linear-progress-overall',
progressNode: 'linear-progress-node'
},
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
@@ -173,6 +191,7 @@ export type TestIdValue =
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

@@ -1,53 +1,31 @@
import { test as base } from '@playwright/test'
import type { WebSocketRoute } from '@playwright/test'
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
getWebSocket: () => Promise<WebSocketRoute>
}>({
ws: [
async ({ page }, use) => {
// Each time a page loads, to catch navigations
page.on('load', async () => {
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = (window.__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}
getWebSocket: [
async ({ context }, use) => {
let latest: WebSocketRoute | undefined
let resolve: ((ws: WebSocketRoute) => void) | undefined
await context.routeWebSocket(/\/ws/, (ws) => {
const server = ws.connectToServer()
server.onMessage((message) => {
ws.send(message)
})
latest = ws
resolve?.(ws)
})
await use({
async trigger(data, url) {
// Trigger a websocket event on the page
await page.evaluate(
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.href)
u.hash = ''
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = window.__ws__![url]
ws.dispatchEvent(
new MessageEvent('message', {
data
})
)
},
[JSON.stringify(data), url]
)
}
await use(() => {
if (latest) return Promise.resolve(latest)
return new Promise<WebSocketRoute>((r) => {
resolve = r
})
})
},
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
{ auto: true }
]
})

View File

@@ -1,7 +1,6 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '@/schemas/apiSchema'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type { WorkspaceStore } from '@e2e/types/globals'
@@ -18,8 +17,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
*/
test('Does not auto-queue multiple changes at a time', async ({
comfyPage,
ws
getWebSocket
}) => {
const ws = await getWebSocket()
// Enable change auto-queue mode
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled')
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
const triggerStatus = (queueSize: number) => {
ws.send(
JSON.stringify({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
}
}
}
}
} as StatusWsMessage)
})
)
}
// Extract the width from the queue response
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
).toBe(1)
// Trigger a status update so auto-queue re-runs
await triggerStatus(1)
await triggerStatus(0)
triggerStatus(1)
triggerStatus(0)
// Ensure the queued width is the last queued value
expect(

View File

@@ -1,7 +1,7 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
} from '@e2e/fixtures/ComfyPage'
test.describe('App mode welcome states', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -0,0 +1,413 @@
import type { WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { comfyPageFixture, comfyExpect as expect } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
// Node IDs from the default workflow (browser_tests/assets/default.json, 7 nodes)
const SAVE_IMAGE_NODE = '9'
const KSAMPLER_NODE = '3'
const ALL_NODE_IDS = ['4', '6', '7', '5', KSAMPLER_NODE, '8', SAVE_IMAGE_NODE]
/** Queue a prompt, intercept it, and send execution_start. */
async function startExecution(
comfyPage: ComfyPage,
ws: WebSocketRoute,
exec?: ExecutionHelper
) {
exec ??= new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
// Allow storeJob() to complete before sending WS events
await comfyPage.nextFrame()
exec.executionStart(jobId)
return { exec, jobId }
}
function imageOutput(...filenames: string[]) {
return {
images: filenames.map((filename) => ({
filename,
subfolder: '',
type: 'output'
}))
}
}
test.describe('Output History', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await comfyPage.nextFrame()
})
test('Skeleton appears on execution start', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
})
test('Latent preview replaces skeleton', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Image output replaces skeleton on executed', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('test_output.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Multiple outputs from single execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput('output_001.png', 'output_002.png', 'output_003.png')
)
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
})
test('Video output renders video element', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, {
gifs: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
})
await expect(
comfyPage.appMode.outputHistory.videoOutputs.first()
).toBeVisible()
})
test('Cancel button sends interrupt during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const job: RawJobListItem = {
id: jobId,
status: 'in_progress',
create_time: Date.now() / 1000,
priority: 0
}
await comfyPage.page.route(
/\/api\/jobs\?status=in_progress/,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: [job],
pagination: { offset: 0, limit: 200, total: 1, has_more: false }
})
})
},
{ times: 1 }
)
// Trigger queue refresh
exec.status(1)
await comfyPage.nextFrame()
await expect(comfyPage.appMode.cancelRunButton).toBeVisible()
await comfyPage.page.route('**/interrupt', (route) =>
route.fulfill({ status: 200 })
)
const interruptRequest = comfyPage.page.waitForRequest('**/interrupt')
await comfyPage.appMode.cancelRunButton.click()
await interruptRequest
})
test('Full execution lifecycle cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton appears
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
// Latent preview replaces skeleton
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
// Image output replaces latent
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('lifecycle_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
// Job completes with history mock - in-progress items fully resolved
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'lifecycle_out.png')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
// Output now appears as a history item
await expect(
comfyPage.appMode.outputHistory.historyItems.first()
).toBeVisible()
})
test('Auto-selection follows latest in-progress item', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton is auto-selected
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toBeVisible()
// First image is auto-selected
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
// Second image arrives - selection auto-follows without user click
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /second\.png/)
})
test('Clicking item breaks auto-follow during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Send first image
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
// Click the first image to break auto-follow
await comfyPage.appMode.outputHistory.inProgressItems.first().click()
// Send second image - selection should NOT move to it
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(2)
// The first item should still be selected (not auto-followed to second)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toHaveCount(1)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
})
test('Non-output node executed events are filtered', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
// KSampler is not an output node - should be filtered
exec.executed(jobId, KSAMPLER_NODE, imageOutput('ksampler_out.png'))
await comfyPage.nextFrame()
// KSampler output should not create image outputs
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(0)
// Now send from the actual output node (SaveImage)
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('save_image_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
})
test('In-progress items are outside the scrollable area', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
// Complete one execution with 100 image outputs
const { exec, jobId } = await startExecution(comfyPage, ws)
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput(
...Array.from(
{ length: 100 },
(_, i) => `image_${String(i).padStart(3, '0')}.png`
)
)
)
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'image_000.png')
await expect(comfyPage.appMode.outputHistory.historyItems).toHaveCount(100)
// First history item is visible before scrolling
const firstItem = comfyPage.appMode.outputHistory.historyItems.first()
await expect(firstItem).toBeInViewport()
// Scroll the history feed all the way to the right
await comfyPage.appMode.outputHistory.outputs.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// First history item is now off-screen
await expect(firstItem).not.toBeInViewport()
// Start a new execution to get an in-progress item
await startExecution(comfyPage, ws, exec)
// In-progress item is visible despite scrolling
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeInViewport()
})
test('Execution error cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
})
test('Progress bars update for both node and overall progress', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const {
inProgressItems,
headerOverallProgress,
headerNodeProgress,
itemOverallProgress,
itemNodeProgress
} = comfyPage.appMode.outputHistory
await expect(inProgressItems.first()).toBeVisible()
// Initially both bars are at 0%
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler starts executing - node progress at 50%
exec.executing(jobId, KSAMPLER_NODE)
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
// Overall still 0% - no nodes completed yet
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler finishes - overall advances (1 of 7 nodes)
exec.executed(jobId, KSAMPLER_NODE, {})
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
await expect(headerOverallProgress).toHaveAttribute('style', pct)
await expect(itemOverallProgress).toHaveAttribute('style', pct)
// Node progress reaches 100%
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
// Complete remaining nodes - overall reaches 100%
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
for (const nodeId of remainingNodes) {
exec.executing(jobId, nodeId)
exec.executed(jobId, nodeId, {})
}
await expect(headerOverallProgress).toHaveAttribute(
'style',
/width:\s*100%/
)
await expect(itemOverallProgress).toHaveAttribute('style', /width:\s*100%/)
})
})

View File

@@ -103,6 +103,7 @@ async function rerun(e: Event) {
</Button>
<Button
v-if="isWorkflowActive && !selectedItem"
data-testid="linear-cancel-run"
variant="destructive"
@click="cancelActiveWorkflowJobs()"
>

View File

@@ -22,6 +22,7 @@ const executionStore = useExecutionStore()
</script>
<template>
<div
v-bind="$attrs"
:class="
cn(
'relative h-2 bg-secondary-background transition-opacity',
@@ -32,11 +33,13 @@ const executionStore = useExecutionStore()
"
>
<div
data-testid="linear-progress-overall"
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:class="cn(rounded && 'rounded-sm')"
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
/>
<div
data-testid="linear-progress-node"
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:class="cn(rounded && 'rounded-sm')"
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"

View File

@@ -327,6 +327,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
:key="`${item.id}-${item.state}`"
:ref="selectedRef(`slot:${item.id}`)"
v-bind="itemAttrs(`slot:${item.id}`)"
data-testid="linear-in-progress-item"
:class="itemClass"
@click="store.select(`slot:${item.id}`)"
>
@@ -359,6 +360,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
:key
:ref="selectedRef(`history:${asset.id}:${key}`)"
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
data-testid="linear-history-item"
:class="itemClass"
@click="store.select(`history:${asset.id}:${key}`)"
>

View File

@@ -58,6 +58,7 @@ function clearQueue(close: () => void) {
<div
v-if="queueCount > 1"
aria-hidden="true"
data-testid="linear-job-badge"
class="absolute top-0 right-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-background text-xs text-text-primary"
v-text="queueCount"
/>

View File

@@ -15,6 +15,7 @@ const { output } = defineProps<{
<template>
<img
v-if="getMediaType(output) === 'images'"
data-testid="linear-image-output"
class="block size-10 rounded-sm bg-secondary-background object-cover"
loading="lazy"
width="40"
@@ -23,6 +24,7 @@ const { output } = defineProps<{
/>
<template v-else-if="getMediaType(output) === 'video'">
<video
data-testid="linear-video-output"
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
preload="metadata"
width="40"

View File

@@ -9,10 +9,19 @@ const { latentPreview } = defineProps<{
<div class="w-10">
<img
v-if="latentPreview"
data-testid="linear-latent-preview"
class="block size-10 rounded-sm object-cover"
:src="latentPreview"
/>
<div v-else class="skeleton-shimmer size-10 rounded-sm" />
<LinearProgressBar class="mt-1 h-1 w-10" rounded />
<div
v-else
data-testid="linear-skeleton"
class="skeleton-shimmer size-10 rounded-sm"
/>
<LinearProgressBar
data-testid="linear-item-progress-bar"
class="mt-1 h-1 w-10"
rounded
/>
</div>
</template>

View File

@@ -246,7 +246,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
if (!isJobForActiveWorkflow(jobId)) return
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
if (!sel || isFollowing.value) {
selectedId.value = slotId
isFollowing.value = true
return

View File

@@ -154,6 +154,7 @@ function dragDrop(e: DragEvent) {
@drop="dragDrop"
>
<LinearProgressBar
data-testid="linear-header-progress-bar"
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview