mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-04 11:39:09 +00:00
Compare commits
9 Commits
perf/test-
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b611a844 | ||
|
|
e1455ef3bb | ||
|
|
218dd709ae | ||
|
|
133634445a | ||
|
|
193ca19a02 | ||
|
|
de328cdbc2 | ||
|
|
15e2311bd5 | ||
|
|
f73f23e895 | ||
|
|
51620b1829 |
67
browser_tests/fixtures/components/OutputHistory.ts
Normal file
67
browser_tests/fixtures/components/OutputHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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"]`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
import { OutputHistoryComponent } from '../components/OutputHistory'
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
@@ -13,12 +14,14 @@ export class AppModeHelper {
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -78,6 +81,10 @@ export class AppModeHelper {
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
get cancelRunButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.outputHistory.cancelRun)
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
@@ -99,41 +126,69 @@ export class BuilderSelectHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
/**
|
||||
* Click a widget on the canvas to select it as a builder input.
|
||||
* @param nodeTitle The displayed title of the node.
|
||||
* @param widgetName The widget name to click.
|
||||
*/
|
||||
async selectInputWidget(nodeTitle: string, widgetName: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
}
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
get previewWidgetLabels(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an IoItem from one index to another in the inputs step.
|
||||
* Items are identified by their 0-based position among visible IoItems.
|
||||
*/
|
||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a widget item from one index to another in the preview/arrange step.
|
||||
*/
|
||||
async dragPreviewItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.widgetItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an output node on the canvas to select it as a builder output.
|
||||
* @param nodeTitle The displayed title of the output node.
|
||||
*/
|
||||
async selectOutputNode(nodeTitle: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
207
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
207
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { MockWebSocket } from '../ws'
|
||||
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 assets: ComfyPage['assets']
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
private readonly mock: MockWebSocket
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.assets = comfyPage.assets
|
||||
}
|
||||
|
||||
private get ws() {
|
||||
return this.mock.ws
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.QueuePrompt')
|
||||
})
|
||||
await prompted
|
||||
|
||||
// Prevent real server events from interfering with test-controlled execution
|
||||
this.mock.stopServerForwarding()
|
||||
|
||||
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 `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 } } }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,23 @@ export const TestIds = {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
opensAs: 'builder-opens-as',
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label'
|
||||
},
|
||||
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'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
@@ -125,6 +141,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.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
|
||||
@@ -1,53 +1,45 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
export interface MockWebSocket {
|
||||
/** The intercepted WebSocket route. */
|
||||
ws: WebSocketRoute
|
||||
/** Stop forwarding real server messages to the page. */
|
||||
stopServerForwarding(): void
|
||||
}
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
||||
getWebSocket: () => Promise<MockWebSocket>
|
||||
}>({
|
||||
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: MockWebSocket | undefined
|
||||
let resolve: ((mock: MockWebSocket) => void) | undefined
|
||||
|
||||
await context.routeWebSocket(/\/ws/, (ws) => {
|
||||
let forwarding = true
|
||||
const server = ws.connectToServer()
|
||||
server.onMessage((message) => {
|
||||
if (forwarding) ws.send(message)
|
||||
})
|
||||
|
||||
const mock: MockWebSocket = {
|
||||
ws,
|
||||
stopServerForwarding() {
|
||||
forwarding = false
|
||||
}
|
||||
}
|
||||
latest = mock
|
||||
resolve?.(mock)
|
||||
})
|
||||
|
||||
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<MockWebSocket>((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 }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { comfyExpect } from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
@@ -13,41 +20,48 @@ import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
* @param prepareGraph - Optional callback to transform the graph before
|
||||
* entering builder. Receives the KSampler node ref and returns the
|
||||
* input node title and widget names to select.
|
||||
* Defaults to KSampler with its first widget.
|
||||
* Mutually exclusive with widgetNames.
|
||||
* @param widgetNames - Widget names to select from the KSampler node.
|
||||
* Only used when prepareGraph is not provided.
|
||||
* Mutually exclusive with prepareGraph.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
prepareGraph?: (ksampler: NodeReference) => Promise<BuilderSetupResult>,
|
||||
widgetNames?: string[]
|
||||
): Promise<void> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
const { inputNodeTitle, widgetNames: inputWidgets } = prepareGraph
|
||||
? await prepareGraph(ksampler)
|
||||
: { inputNodeTitle: 'KSampler', widgetNames: widgetNames ?? ['seed'] }
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
for (const name of inputWidgets) {
|
||||
await appMode.select.selectInputWidget(inputNodeTitle, name)
|
||||
}
|
||||
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
): Promise<void> {
|
||||
await setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -58,10 +72,52 @@ export async function setupSubgraphBuilder(
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
return {
|
||||
inputNodeTitle: 'New Subgraph',
|
||||
widgetNames: ['seed']
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
export async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph' = 'App'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
export async function openWorkflowFromSidebar(
|
||||
comfyPage: ComfyPage,
|
||||
name: string
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyExpect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { WorkspaceStore } from '../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(
|
||||
|
||||
163
browser_tests/tests/builderReorder.spec.ts
Normal file
163
browser_tests/tests/builderReorder.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
|
||||
async function saveCloseAndReopenAsApp(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Drag first input to last position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
|
||||
test('Drag last input to first position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(2, 0)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'cfg',
|
||||
'seed',
|
||||
'steps'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'cfg',
|
||||
'seed',
|
||||
'steps'
|
||||
])
|
||||
})
|
||||
|
||||
test('Drag input to middle position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(0, 1)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'seed',
|
||||
'cfg'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'seed',
|
||||
'cfg'
|
||||
])
|
||||
})
|
||||
|
||||
test('Reorder in preview step reflects in app mode after save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragPreviewItem(0, 2)
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
const workflowName = `${Date.now()} reorder-preview`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
|
||||
test('Reorder inputs persists after save and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
const workflowName = `${Date.now()} reorder-persist`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,45 +2,14 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
* fill name + view type, and save.
|
||||
@@ -203,10 +172,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
// Select I/O to enable the button
|
||||
await appMode.steps.goToInputs()
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await appMode.select.selectInputWidget(ksampler)
|
||||
await appMode.select.selectInputWidget('KSampler', 'seed')
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
|
||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
|
||||
342
browser_tests/tests/outputHistory.spec.ts
Normal file
342
browser_tests/tests/outputHistory.spec.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '../../src/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 type { MockWebSocket } from '../fixtures/ws'
|
||||
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const SAVE_IMAGE_NODE = '9'
|
||||
const KSAMPLER_NODE = '3'
|
||||
|
||||
/** Queue a prompt, intercept it, and send execution_start. */
|
||||
async function startExecution(comfyPage: ComfyPage, mock: MockWebSocket) {
|
||||
const exec = new ExecutionHelper(comfyPage, mock)
|
||||
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 mock = await getWebSocket()
|
||||
await startExecution(comfyPage, mock)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.skeletons.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Latent preview replaces skeleton', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.skeletons.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.latentPreviews.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Image output replaces skeleton on executed', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test('Multiple outputs from single execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, {
|
||||
images: [
|
||||
{ filename: 'output_001.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'output_002.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'output_003.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
})
|
||||
|
||||
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('Video output renders video element', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
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 mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
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()
|
||||
|
||||
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 mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
// 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 mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
// Skeleton is auto-selected
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||
).toBeVisible()
|
||||
|
||||
// After executed, the image item should be auto-selected
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('auto_select.png'))
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||
'linear-image-output'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking item breaks auto-follow during execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
// 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
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||
'linear-image-output'
|
||||
)
|
||||
).toBeVisible()
|
||||
// And there should be exactly one selected item
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||
).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Non-output node executed events are filtered', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
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 mock = await getWebSocket()
|
||||
|
||||
// Complete one execution with 100 image outputs
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
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, mock)
|
||||
|
||||
// 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 mock = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, mock)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
|
||||
|
||||
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -170,6 +170,7 @@ defineExpose({ handleDragDrop })
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
: undefined
|
||||
"
|
||||
data-testid="builder-widget-item"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
@@ -181,6 +182,7 @@ defineExpose({ handleDragDrop })
|
||||
>
|
||||
<span
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
data-testid="builder-widget-label"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
</span>
|
||||
|
||||
@@ -103,6 +103,7 @@ async function rerun(e: Event) {
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isWorkflowActive && !selectedItem"
|
||||
data-testid="linear-cancel-run"
|
||||
variant="destructive"
|
||||
@click="cancelActiveWorkflowJobs()"
|
||||
>
|
||||
|
||||
@@ -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}`)"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,10 +9,15 @@ 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" />
|
||||
<div
|
||||
v-else
|
||||
data-testid="linear-skeleton"
|
||||
class="skeleton-shimmer size-10 rounded-sm"
|
||||
/>
|
||||
<LinearProgressBar class="mt-1 h-1 w-10" rounded />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user