Compare commits

...

19 Commits

Author SHA1 Message Date
Benjamin Lu
dfefae59e0 test: update job history mock setup 2026-04-25 01:49:30 -07:00
Benjamin Lu
87d7f993b5 test: cover job history sidebar and remove legacy asset helper 2026-04-25 01:48:56 -07:00
Benjamin Lu
b638eca96e test: update asset action mock setup 2026-04-25 01:48:17 -07:00
Benjamin Lu
0963185ef8 test: cover assets sidebar actions 2026-04-25 01:47:48 -07:00
Benjamin Lu
b3943df802 test: rename asset scenario mocks 2026-04-25 01:47:13 -07:00
Benjamin Lu
bbe3248b8e test: remove asset fixture unit tests 2026-04-25 01:45:48 -07:00
Benjamin Lu
8e989e57fb test: add asset scenario fixture and browsing coverage 2026-04-25 01:45:48 -07:00
Benjamin Lu
bb79f8dee0 test: simplify jobs api browser fixture 2026-04-25 01:45:02 -07:00
Benjamin Lu
699e6995a9 test: remove fixture unit test scaffolding 2026-04-15 21:04:30 -07:00
Benjamin Lu
ea35401536 test: align in-memory jobs limit handling 2026-04-15 20:58:29 -07:00
Benjamin Lu
f257b7136e test: add in-memory jobs backend fixture 2026-04-15 15:13:55 -07:00
Benjamin Lu
0e62ef0cbc test: extract asset api browser fixture 2026-04-15 15:09:40 -07:00
pythongosssss
a8e1fa8bef test: add regression test for WEBP RIFF padding (#8527) (#11267)
## Summary

Add a regression test for #8527 (handle RIFF padding for odd-sized WEBP
chunks). The fix added + (chunk_length % 2) to the chunk-stride
calculation in getWebpMetadata so EXIF chunks following an odd-sized
chunk are still located correctly. There was no existing unit test
covering getWebpMetadata, so without a regression test the fix could
silently break in a future
  refactor. 

## Changes

- **What**: 
- New unit test file src/scripts/pnginfo.test.ts covering
getWebpMetadata's RIFF chunk traversal.
- Helpers build a minimal in-memory WEBP with one VP8 chunk of
configurable length followed by an EXIF chunk encoding workflow:<json>.
- Odd-length case (regression for #8527): without the % 2 padding
adjustment, the parser walks one byte short and returns {}.
- Even-length case: guards against an over-correction that always adds
1.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11267-test-add-regression-test-for-WEBP-RIFF-padding-8527-3436d73d36508117a66edf3cb108ded0)
by [Unito](https://www.unito.io)
2026-04-15 18:14:49 +00:00
pythongosssss
83ceef8cb3 test: add regression test for non-string serverLogs (#8460) (#11268)
## Summary

Add a regression test for #8460 (handle non-string `serverLogs` in error
report). The fix added `typeof error.serverLogs === 'string' ? ... :
JSON.stringify(...)` in `errorReportUtil.ts` so object-shaped logs no
longer render as `[object Object]`. There was no existing unit test for
`generateErrorReport`, so this regression could silently return.

## Changes

- **What**: New unit test file `src/utils/errorReportUtil.test.ts`
covering `generateErrorReport`'s `serverLogs` rendering.
- String case: verifies plain-string logs still appear verbatim and
`[object Object]` is absent.
- Object case (regression for #8460): verifies object logs are
JSON-stringified instead of coerced to `[object Object]`.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11268-test-add-regression-test-for-non-string-serverLogs-8460-3436d73d36508195a32fc559ab7ce5bb)
by [Unito](https://www.unito.io)
2026-04-15 18:14:17 +00:00
Christian Byrne
4885ef856c [chore] Update Comfy Registry API types from comfy-api@113318d (#11261)
## Automated API Type Update

This PR updates the Comfy Registry API types from the latest comfy-api
OpenAPI specification.

- API commit: 113318d
- Generated on: 2026-04-15T04:26:33Z

These types are automatically generated using openapi-typescript.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11261-chore-Update-Comfy-Registry-API-types-from-comfy-api-113318d-3436d73d3650816784d4efd98d6a665a)
by [Unito](https://www.unito.io)

Co-authored-by: bigcat88 <13381981+bigcat88@users.noreply.github.com>
2026-04-15 11:16:10 -07:00
Christian Byrne
873a75d607 test: add unit tests for usePainter composable (#11137)
## Summary

Add 25 behavioral unit tests for `usePainter` composable, bringing
coverage from 0% to ~35% lines / ~57% functions.

## Changes

- **What**: New test file `src/composables/painter/usePainter.test.ts`
covering widget sync, settings persistence, canvas sizing, brush display
scaling, serialization, restore, pointer event guards, and cursor
visibility.

## Review Focus

- Mock patterns: singleton factory mocks for stores, wrapper component
for lifecycle hooks
- Test coverage prioritization: focused on mount-time sync, reactive
watchers, and computed behavior rather than canvas pixel output

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11137-test-add-unit-tests-for-usePainter-composable-33e6d73d36508147bde7e9c349c743ca)
by [Unito](https://www.unito.io)
2026-04-15 11:13:31 -07:00
pythongosssss
ecb6fbe8fb test: Add waitForWorkflowIdle & remove redundant nextFrame (#11264)
## Summary

More cleanup and reliability

## Changes

- **What**: 
- Add wait for idle
- Remove redundant nextFrames

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11264-test-Add-waitForWorkflowIdle-remove-redundant-nextFrame-3436d73d3650812c837ac7503ce0947b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-15 16:52:41 +00:00
Alexander Brown
52ccd9ed1a refactor: internalize nextFrame() into fixture/helper methods (#11166)
## Summary

Internalize `nextFrame()` calls into fixture/helper methods so spec
authors don't need to remember to call it after common operations.
`nextFrame()` waits for one `requestAnimationFrame` (~16ms) — an extra
call is always safe, making this a low-risk refactor.

## Changes

### Phase 1: `SettingsHelper.setSetting()`
`setSetting()` now calls `nextFrame()` internally. Removed 15 redundant
calls across 7 files.

### Phase 2: `CommandHelper.executeCommand()`
`executeCommand()` now calls `nextFrame()` internally. Removed 15
redundant calls across 7 files, including the now-redundant call in
`AppModeHelper.toggleAppMode()`.

### Phase 3: `WorkflowHelper.loadGraphData()`
New helper wraps `page.evaluate(loadGraphData)` + `nextFrame()`.
Migrated `SubgraphHelper.serializeAndReload()` and `groupNode.spec.ts`.

### Phase 4: `NodeReference` cleanup
Removed redundant `nextFrame()` from `copy()`, `convertToGroupNode()`,
`resizeNode()`, `dragTextEncodeNode2()`, and
`convertDefaultKSamplerToSubgraph()`. Removed 6 spec-level calls after
`node.click('title')`.

### Phase 5: `KeyboardHelper.press()` and `delete()`
New convenience methods that press a key and wait one frame. Converted
40 `canvas.press(key)` + `nextFrame()` pairs across 13 spec files.

### Phase 6: `ComfyPage.expectScreenshot()`
New helper combines `nextFrame()` + `toHaveScreenshot()`. Converted 45
pairs across 12 spec files.

## Total impact
- **~130 redundant `nextFrame()` calls eliminated** across ~35
spec/helper files
- **3 new helper methods** added (`loadGraphData`, `press`/`delete`,
`expectScreenshot`)
- **2 existing methods** enhanced (`setSetting`, `executeCommand`)

## What was NOT changed
- `performance.spec.ts` frame-counting loops (intentional)
- `ComfyMouse.ts` / `CanvasHelper.ts` (already internalized)
- `SubgraphHelper.packAllInteriorNodes()` (deliberate orchestration)
- Builder helpers (already internalized)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11166-refactor-internalize-nextFrame-into-fixture-helper-methods-33f6d73d3650817bb5f6fb46e396085e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-15 15:25:47 +00:00
Kelly Yang
92ad6fc798 test: address review nits for image compare E2E (#11260)
## Summary
A follow-up PR of #11196.

| # | Nit | Action | Reason |
| :--- | :--- | :--- | :--- |
| 1 | Replace `page.on('pageerror')` with request-wait | **Left as-is**
| The `pageErrors` array is an accumulator checked at the end via
`expect(pageErrors).toHaveLength(0)` – the goal is to assert that broken
image URLs don't surface as uncaught JS exceptions during the test run.
A request-wait can't substitute for that behavioral assertion, so the
listener pattern is intentional here. |
| 2 | Move helpers to a `vueNodes.getImageCompareHelper()` subclass |
**Left as-is** | Helpers such as `setImageCompareValue` and
`moveToPercentage` are only used in this file, making local
encapsulation enough. Extracting them to a page object would increase
the file/interface surface area and violate YAGNI; additionally,
`AGENTS.md` clearly states to "minimize the exported values of each
module. |
| 3 | Use `TestIds` enum for test ID strings | **Fixed** – added
`imageCompare` section to `TestIds` in `selectors.ts`; replaced all 8
inline string IDs in `imageCompare.spec.ts` with
`TestIds.imageCompare.*` references | The project already has a
`TestIds` convention for centralizing test IDs. Inline strings create
drift risk between the Vue component and the test file. |
| 4 | Move `expect.poll` bounding box check to helper/page object |
**Left as-is** | This logic already lives inside `moveToPercentage`,
which is a local helper. Moving it further to a page object is the same
refactor as #2 above. |
| 5 | Remove `// ---` style section header comments | **Fixed** –
removed all 8 divider blocks from `imageCompare.spec.ts` | Consistent
with project guidelines and your explicit preference. Test names already
describe what each block does. |
| 6 | Name magic numbers `400` and `350` | **Fixed** – introduced
`minWidth = 400` and `minHeight = 350` constants in the test |
Descriptive names make the constraint self-documenting and easier to
update if the workflow asset changes. |

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E test code and shared
selector constants, with no production logic impacted.
> 
> **Overview**
> **E2E Image Compare tests now use centralized selectors.** Adds an
`imageCompare` section to `TestIds` and updates `imageCompare.spec.ts`
to reference `TestIds.imageCompare.*` instead of inline `data-testid`
strings.
> 
> Cleans up the spec by removing divider comments and naming the minimum
size magic numbers (`minWidth`, `minHeight`) used in the node sizing
assertion.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ece25be5cc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11260-test-address-review-nits-for-image-compare-E2E-3436d73d365081a69cacc1fff390035a)
by [Unito](https://www.unito.io)
2026-04-15 10:50:44 -04:00
79 changed files with 3306 additions and 995 deletions

View File

@@ -10,7 +10,7 @@ import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { sleep } from '@e2e/fixtures/utils/timing'
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
@@ -23,6 +23,7 @@ import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog'
import {
AssetsSidebarTab,
JobHistorySidebarTab,
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
@@ -30,9 +31,6 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
@@ -65,6 +63,7 @@ class ComfyPropertiesPanel {
class ComfyMenu {
private _assetsTab: AssetsSidebarTab | null = null
private _jobHistoryTab: JobHistorySidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _nodeLibraryTabV2: NodeLibrarySidebarTabV2 | null = null
@@ -103,6 +102,11 @@ class ComfyMenu {
return this._assetsTab
}
get jobHistoryTab() {
this._jobHistoryTab ??= new JobHistorySidebarTab(this.page)
return this._jobHistoryTab
}
get workflowsTab() {
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
return this._workflowsTab
@@ -178,8 +182,6 @@ export class ComfyPage {
public readonly bottomPanel: BottomPanel
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
@@ -232,8 +234,6 @@ export class ComfyPage {
this.bottomPanel = new BottomPanel(page)
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
@@ -336,9 +336,7 @@ export class ComfyPage {
}
async nextFrame() {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
await nextFrame(this.page)
}
async delay(ms: number) {
@@ -393,6 +391,27 @@ export class ComfyPage {
return this.page.locator('.dom-widget')
}
async expectScreenshot(
locator: Locator,
name: string | string[],
options?: {
animations?: 'disabled' | 'allow'
caret?: 'hide' | 'initial'
mask?: Array<Locator>
maskColor?: string
maxDiffPixelRatio?: number
maxDiffPixels?: number
omitBackground?: boolean
scale?: 'css' | 'device'
stylePath?: string | Array<string>
threshold?: number
timeout?: number
}
): Promise<void> {
await this.nextFrame()
await comfyExpect(locator).toHaveScreenshot(name, options)
}
async setFocusMode(focusMode: boolean) {
await this.page.evaluate((focusMode) => {
;(window.app!.extensionManager as WorkspaceStore).focusMode = focusMode
@@ -480,7 +499,6 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
assetApi: async ({ page }, use) => {
const assetApi = createAssetHelper(page)
await use(assetApi)
await assetApi.clearMocks()
}
})

View File

@@ -0,0 +1,14 @@
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
export const assetScenarioFixture = jobsApiMockFixture.extend<{
assetScenario: AssetScenarioHelper
}>({
assetScenario: async ({ page, jobsApi }, use) => {
const assetScenario = new AssetScenarioHelper(page, jobsApi)
await use(assetScenario)
await assetScenario.clear()
}
})

View File

@@ -204,6 +204,43 @@ export class WorkflowsSidebarTab extends SidebarTab {
}
}
export class JobHistorySidebarTab extends SidebarTab {
public readonly root: Locator
public readonly searchInput: Locator
public readonly allTab: Locator
public readonly completedTab: Locator
public readonly failedTab: Locator
public readonly moreOptionsButton: Locator
public readonly clearQueuedButton: Locator
public readonly jobRows: Locator
constructor(public override readonly page: Page) {
super(page, 'job-history')
this.root = page.locator('.sidebar-content-container')
this.searchInput = this.root.getByPlaceholder('Search...')
this.allTab = this.root.getByRole('tab', { name: 'All', exact: true })
this.completedTab = this.root.getByRole('tab', {
name: 'Completed',
exact: true
})
this.failedTab = this.root.getByRole('tab', { name: 'Failed', exact: true })
this.moreOptionsButton = this.root.getByLabel('More options')
this.clearQueuedButton = this.root.getByRole('button', {
name: 'Clear queue'
})
this.jobRows = this.root.locator('[data-job-id]')
}
jobRow(jobId: string) {
return this.root.locator(`[data-job-id="${jobId}"]`)
}
override async open() {
await super.open()
await this.searchInput.waitFor({ state: 'visible' })
}
}
export class ModelLibrarySidebarTab extends SidebarTab {
public readonly searchInput: Locator
public readonly modelTree: Locator
@@ -249,70 +286,62 @@ export class ModelLibrarySidebarTab extends SidebarTab {
}
export class AssetsSidebarTab extends SidebarTab {
// --- Tab navigation ---
public readonly root: Locator
public readonly generatedTab: Locator
public readonly importedTab: Locator
// --- Empty state ---
public readonly emptyStateMessage: Locator
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
// --- View mode ---
public readonly listViewOption: Locator
public readonly gridViewOption: Locator
// --- Sort options (cloud-only, shown inside settings popover) ---
public readonly backToAssetsButton: Locator
public readonly copyJobIdButton: Locator
public readonly previewDialog: Locator
public readonly sortNewestFirst: Locator
public readonly sortOldestFirst: Locator
// --- Asset cards ---
public readonly assetCards: Locator
public readonly selectedCards: Locator
// --- List view items ---
public readonly listViewItems: Locator
// --- Selection footer ---
public readonly selectionFooter: Locator
public readonly selectionCountButton: Locator
public readonly deselectAllButton: Locator
public readonly deleteSelectedButton: Locator
public readonly downloadSelectedButton: Locator
// --- Folder view ---
public readonly backToAssetsButton: Locator
// --- Loading ---
public readonly skeletonLoaders: Locator
constructor(public override readonly page: Page) {
super(page, 'assets')
this.root = page.locator('.sidebar-content-container')
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
this.importedTab = page.getByRole('tab', { name: 'Imported' })
this.emptyStateMessage = page.getByText(
'Upload files or generate content to see them here'
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.searchInput = this.root.getByPlaceholder(/Search Assets/i)
this.settingsButton = this.root.getByLabel('View settings')
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.backToAssetsButton = page.getByRole('button', {
name: 'Back to all assets'
})
this.copyJobIdButton = page.getByRole('button', {
name: 'Copy job ID'
})
this.previewDialog = page.getByRole('dialog', { name: 'Gallery' })
this.sortNewestFirst = page.getByText('Newest first')
this.sortOldestFirst = page.getByText('Oldest first')
this.assetCards = page
this.assetCards = this.root
.getByRole('button')
.and(page.locator('[data-selected]'))
this.selectedCards = page.locator('[data-selected="true"]')
this.listViewItems = page.locator(
'.sidebar-content-container [role="button"][tabindex="0"]'
)
this.selectionFooter = page
.locator('.sidebar-content-container')
.locator('..')
.locator('[class*="h-18"]')
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
.and(this.root.locator('[data-selected]'))
this.selectedCards = this.root.locator('[data-selected="true"]')
this.listViewItems = this.root.getByRole('button', { name: /asset$/i })
this.selectionFooter = this.root.locator('..').getByRole('toolbar', {
name: 'Selected asset actions'
})
this.selectionCountButton = this.root
.getByRole('button', { name: /Assets Selected:/ })
.or(page.getByText(/Assets Selected: \d+/))
.first()
this.deselectAllButton = page.getByText('Deselect all')
this.deleteSelectedButton = page
.getByTestId('assets-delete-selected')
@@ -322,28 +351,113 @@ export class AssetsSidebarTab extends SidebarTab {
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'
)
this.skeletonLoaders = this.root.locator('.animate-pulse')
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
previewImage(filename: string) {
return this.previewDialog.getByRole('img', { name: filename })
}
asset(name: string) {
return this.getAssetCardByName(name)
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
return this.assetCards.filter({ hasText: name }).first()
}
contextMenuItem(label: string) {
return this.page.locator('.p-contextmenu').getByText(label)
}
contextMenuAction(label: string) {
return this.contextMenuItem(label)
}
async showGenerated() {
await this.switchToGenerated()
}
async showImported() {
await this.switchToImported()
}
async search(query: string) {
await this.searchInput.fill(query)
}
async switchToListView() {
await this.openSettingsMenu()
await this.listViewOption.click()
}
async switchToGridView() {
await this.openSettingsMenu()
await this.gridViewOption.click()
}
async openContextMenuForAsset(name: string) {
await this.asset(name).click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async runContextMenuAction(assetName: string, actionName: string) {
await this.openContextMenuForAsset(assetName)
await this.contextMenuAction(actionName).click()
}
async openAssetPreview(name: string) {
const asset = this.asset(name)
await asset.hover()
const zoomButton = asset.getByLabel('Zoom in')
if (await zoomButton.isVisible().catch(() => false)) {
await zoomButton.click()
return
}
await asset.dblclick()
}
async openOutputFolder(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
await this.backToAssetsButton.waitFor({ state: 'visible' })
}
async toggleStack(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
}
async selectAssets(names: string[]) {
if (names.length === 0) {
return
}
await this.asset(names[0]).click()
for (const name of names.slice(1)) {
await this.asset(name).click({
modifiers: ['ControlOrMeta']
})
}
}
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.root.waitFor({ state: 'visible' })
await this.generatedTab.waitFor({ state: 'visible' })
}

View File

@@ -160,6 +160,15 @@ export class AppModeHelper {
/** Enter builder mode via the "Workflow actions" dropdown. */
async enterBuilder() {
// Wait for any workflow-tab popover to dismiss before clicking —
// the popover overlay can intercept the "Workflow actions" click.
// Best-effort: the popover may or may not exist; if it stays visible
// past the timeout we still proceed with the click.
await this.page
.locator('.workflow-popover-fade')
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
@@ -174,7 +183,6 @@ export class AppModeHelper {
async toggleAppMode() {
await this.comfyPage.workflow.waitForActiveWorkflow()
await this.comfyPage.command.executeCommand('Comfy.ToggleLinear')
await this.comfyPage.nextFrame()
}
/**

View File

@@ -0,0 +1,275 @@
import { readFile } from 'node:fs/promises'
import type { Page, Route } from '@playwright/test'
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
import type {
GeneratedJobFixture,
GeneratedOutputFixture,
ImportedAssetFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import {
buildFileRequestKey,
buildMockAssetFiles,
defaultFileFor
} from '@e2e/fixtures/helpers/mockAssetFiles'
import type { MockAssetFile } from '@e2e/fixtures/helpers/mockAssetFiles'
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
const DEFAULT_FIXTURE_CREATE_TIME = Date.UTC(2024, 0, 1, 0, 0, 0)
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
filename?: string
subfolder?: string
type?: GeneratedOutputFixture['type']
nodeId: string
mediaType?: string
display_name?: string
}
function normalizeOutputFixture(
output: GeneratedOutputFixture
): GeneratedOutputFixture {
const fallback = defaultFileFor(output.filename)
return {
mediaType: 'images',
subfolder: '',
type: 'output',
...output,
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType
}
}
function createOutputFilename(baseFilename: string, index: number): string {
if (index === 0) {
return baseFilename
}
const extensionIndex = baseFilename.lastIndexOf('.')
if (extensionIndex === -1) {
return `${baseFilename}-${index + 1}`
}
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
}
function getPreviewOutput(
previewOutput: JobEntry['preview_output'] | undefined
): MockPreviewOutput | undefined {
return previewOutput as MockPreviewOutput | undefined
}
function outputsFromJobEntry(
job: JobEntry
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
const previewOutput = getPreviewOutput(job.preview_output)
const outputCount = Math.max(job.outputs_count ?? 1, 1)
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
const mediaType: GeneratedOutputFixture['mediaType'] =
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
? previewOutput.mediaType
: 'images'
const outputs = Array.from({ length: outputCount }, (_, index) => ({
filename: createOutputFilename(baseFilename, index),
displayName: index === 0 ? previewOutput?.display_name : undefined,
mediaType,
subfolder: previewOutput?.subfolder ?? '',
type: previewOutput?.type ?? 'output'
}))
return [outputs[0], ...outputs.slice(1)]
}
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
return {
jobId: job.id,
status: job.status,
outputs: outputsFromJobEntry(job),
createTime: job.create_time,
executionStartTime: job.execution_start_time,
executionEndTime: job.execution_end_time,
workflowId: job.workflow_id
}
}
function buildMockJobRecord(job: GeneratedJobFixture) {
const outputs = job.outputs.map(normalizeOutputFixture)
const preview = outputs[0]
const createTime =
job.createTime ??
(job.createdAt
? new Date(job.createdAt).getTime()
: DEFAULT_FIXTURE_CREATE_TIME)
const executionStartTime = job.executionStartTime ?? createTime
const executionEndTime = job.executionEndTime ?? createTime + 2_000
const listItem: JobEntry = {
id: job.jobId,
status: job.status ?? 'completed',
create_time: createTime,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
preview_output: {
filename: preview.filename,
subfolder: preview.subfolder ?? '',
type: preview.type ?? 'output',
nodeId: job.nodeId ?? '5',
mediaType: preview.mediaType ?? 'images',
display_name: preview.displayName
},
outputs_count: outputs.length,
...(job.workflowId ? { workflow_id: job.workflowId } : {})
}
const detail: JobDetailResponse = {
...listItem,
workflow: job.workflow,
outputs: buildMockJobOutputs(job, outputs),
update_time: executionEndTime
}
return { listItem, detail }
}
export class AssetScenarioHelper {
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
private generatedJobs: GeneratedJobFixture[] = []
private importedFiles: ImportedAssetFixture[] = []
private filesByRequestKey = new Map<string, MockAssetFile>()
constructor(
private readonly page: Page,
private readonly jobsApi = new JobsApiMock(page)
) {}
async mockGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
await this.mockScenario({
generated: jobs.map(generatedJobFromJobEntry),
imported: this.importedFiles
})
}
async mockImportedFiles(files: readonly string[]): Promise<void> {
await this.mockScenario({
generated: this.generatedJobs,
imported: files.map((name) => ({ name }))
})
}
async mockEmptyState(): Promise<void> {
await this.mockScenario({ generated: [], imported: [] })
}
async clear(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
this.filesByRequestKey.clear()
await this.jobsApi.clear()
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.viewRouteHandler) {
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
this.viewRouteHandler = null
}
}
private async mockScenario({
generated,
imported
}: {
generated: GeneratedJobFixture[]
imported: ImportedAssetFixture[]
}): Promise<void> {
this.generatedJobs = [...generated]
this.importedFiles = [...imported]
this.filesByRequestKey = buildMockAssetFiles({
generated: this.generatedJobs,
imported: this.importedFiles
})
await this.jobsApi.mockJobs(this.generatedJobs.map(buildMockJobRecord))
await this.ensureInputFilesRoute()
await this.ensureViewRoute()
}
private async ensureInputFilesRoute(): Promise<void> {
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
private async ensureViewRoute(): Promise<void> {
if (this.viewRouteHandler) {
return
}
this.viewRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
const type = url.searchParams.get('type') ?? 'output'
const subfolder = url.searchParams.get('subfolder') ?? ''
if (!filename) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'Missing filename' })
})
return
}
const mockFile =
this.filesByRequestKey.get(
buildFileRequestKey({
filename,
type,
subfolder
})
) ?? defaultFileFor(filename)
if (mockFile.filePath) {
const body = await readFile(mockFile.filePath)
await route.fulfill({
status: 200,
contentType: mockFile.contentType ?? getMimeType(filename),
body
})
return
}
await route.fulfill({
status: 200,
contentType: mockFile.contentType ?? getMimeType(filename),
body: mockFile.textContent ?? ''
})
}
await this.page.route(viewRoutePattern, this.viewRouteHandler)
}
}

View File

@@ -1,251 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const historyRoutePattern = /\/api\/history$/
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
priority: 0,
...overrides
}
}
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now()
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
return Array.from(
{ length: count },
(_, i) =>
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
)
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null =
null
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {}
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = [...this.generatedJobs]
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
/**
* Mock the POST /api/history endpoint used for deleting history items.
* On receiving a `{ delete: [id] }` payload, removes matching jobs from
* the in-memory mock state so subsequent /api/jobs fetches reflect the
* deletion.
*/
async mockDeleteHistory(): Promise<void> {
if (this.deleteHistoryRouteHandler) return
this.deleteHistoryRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const body = request.postDataJSON() as { delete?: string[] }
if (body.delete) {
const idsToRemove = new Set(body.delete)
this.generatedJobs = this.generatedJobs.filter(
(job) => !idsToRemove.has(job.id)
)
}
await route.fulfill({ status: 200, body: '{}' })
}
await this.page.route(historyRoutePattern, this.deleteHistoryRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.deleteHistoryRouteHandler) {
await this.page.unroute(
historyRoutePattern,
this.deleteHistoryRouteHandler
)
this.deleteHistoryRouteHandler = null
}
}
}

View File

@@ -2,6 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position } from '@e2e/fixtures/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CanvasHelper {
constructor(
@@ -10,18 +11,12 @@ export class CanvasHelper {
private resetViewButton: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<number>(requestAnimationFrame)
})
}
async resetView(): Promise<void> {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
}
await this.page.mouse.move(10, 10)
await this.nextFrame()
await nextFrame(this.page)
}
async zoom(deltaY: number, steps: number = 1): Promise<void> {
@@ -29,7 +24,7 @@ export class CanvasHelper {
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
await nextFrame(this.page)
}
async pan(offset: Position, safeSpot?: Position): Promise<void> {
@@ -38,7 +33,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
await nextFrame(this.page)
}
async panWithTouch(offset: Position, safeSpot?: Position): Promise<void> {
@@ -56,22 +51,22 @@ export class CanvasHelper {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
await nextFrame(this.page)
}
async rightClick(x: number = 10, y: number = 10): Promise<void> {
await this.page.mouse.click(x, y, { button: 'right' })
await this.nextFrame()
await nextFrame(this.page)
}
async doubleClick(): Promise<void> {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
await nextFrame(this.page)
}
async click(position: Position): Promise<void> {
await this.canvas.click({ position })
await this.nextFrame()
await nextFrame(this.page)
}
/**
@@ -107,7 +102,7 @@ export class CanvasHelper {
} finally {
for (const mod of modifiers) await this.page.keyboard.up(mod)
}
await this.nextFrame()
await nextFrame(this.page)
}
/**
@@ -116,12 +111,12 @@ export class CanvasHelper {
async mouseDblclickAt(position: Position): Promise<void> {
const abs = await this.toAbsolute(position)
await this.page.mouse.dblclick(abs.x, abs.y)
await this.nextFrame()
await nextFrame(this.page)
}
async clickEmptySpace(): Promise<void> {
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
await this.nextFrame()
await nextFrame(this.page)
}
async dragAndDrop(source: Position, target: Position): Promise<void> {
@@ -129,7 +124,7 @@ export class CanvasHelper {
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
await nextFrame(this.page)
}
async moveMouseToEmptyArea(): Promise<void> {
@@ -152,7 +147,7 @@ export class CanvasHelper {
await this.page.evaluate((s) => {
window.app!.canvas.ds.scale = s
}, scale)
await this.nextFrame()
await nextFrame(this.page)
}
async convertOffsetToCanvas(
@@ -236,12 +231,12 @@ export class CanvasHelper {
// Sweep forward
for (let i = 0; i < steps; i++) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
await nextFrame(this.page)
}
// Sweep back
for (let i = steps; i > 0; i--) {
await this.page.mouse.move(centerX + i * dx, centerY + i * dy)
await this.nextFrame()
await nextFrame(this.page)
}
await this.page.mouse.up({ button: 'middle' })

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import type { KeyCombo } from '@/platform/keybindings/types'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class CommandHelper {
constructor(private readonly page: Page) {}
@@ -20,6 +21,7 @@ export class CommandHelper {
},
{ commandId, metadata }
)
await nextFrame(this.page)
}
async registerCommand(

View File

@@ -5,18 +5,11 @@ import type { Page } from '@playwright/test'
import type { Position } from '@e2e/fixtures/types'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class DragDropHelper {
constructor(private readonly page: Page) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
async dragAndDropExternalResource(
options: {
fileName?: string
@@ -145,7 +138,7 @@ export class DragDropHelper {
await uploadResponsePromise
}
await this.nextFrame()
await nextFrame(this.page)
}
async dragAndDropFile(

View File

@@ -1,18 +1,19 @@
import type { WebSocketRoute } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
/**
* Helper for simulating prompt execution in e2e tests.
*/
export class ExecutionHelper {
private jobCounter = 0
private readonly completedJobs: RawJobListItem[] = []
private readonly completedJobs: JobEntry[] = []
private readonly page: ComfyPage['page']
private readonly command: ComfyPage['command']
private readonly assets: ComfyPage['assets']
private readonly assetScenario: AssetScenarioHelper
constructor(
comfyPage: ComfyPage,
@@ -20,7 +21,7 @@ export class ExecutionHelper {
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
this.assetScenario = new AssetScenarioHelper(comfyPage.page)
}
/**
@@ -172,8 +173,6 @@ export class ExecutionHelper {
/**
* 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,
@@ -193,7 +192,7 @@ export class ExecutionHelper {
})
)
await this.assets.mockOutputHistory(this.completedJobs)
await this.assetScenario.mockGeneratedHistory(this.completedJobs)
this.executionSuccess(jobId)
// Trigger queue/history refresh
this.status(0)

View File

@@ -0,0 +1,183 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
export type MockJobRecord = {
listItem: JobEntry
detail: JobDetailResponse
}
type JobsListMockResponse = Omit<JobsListResponse, 'pagination'> & {
pagination: Omit<JobsListResponse['pagination'], 'limit'> & {
limit: number | null
}
}
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
const value = Number(url.searchParams.get(name))
return Number.isInteger(value) && value > 0 ? value : undefined
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private jobsById = new Map<string, MockJobRecord>()
constructor(private readonly page: Page) {}
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
this.jobsById = new Map(
jobs.map(
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
)
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.jobsById.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
let filteredJobs = Array.from(
this.jobsById.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
const limit = parsePositiveIntegerParam(url, 'limit')
const total = filteredJobs.length
const visibleJobs =
limit === undefined
? filteredJobs.slice(offset)
: filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit: limit ?? null,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListMockResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.jobsById.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.jobsById = new Map(
Array.from(this.jobsById).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.jobsById.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -1,13 +1,21 @@
import type { Locator, Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class KeyboardHelper {
constructor(
private readonly page: Page,
private readonly canvas: Locator
) {}
private async nextFrame(): Promise<void> {
await this.page.evaluate(() => new Promise<number>(requestAnimationFrame))
async press(key: string, locator?: Locator | null): Promise<void> {
const target = locator ?? this.canvas
await target.press(key)
await nextFrame(this.page)
}
async delete(locator?: Locator | null): Promise<void> {
await this.press('Delete', locator)
}
async ctrlSend(
@@ -16,7 +24,7 @@ export class KeyboardHelper {
): Promise<void> {
const target = locator ?? this.page.keyboard
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
await nextFrame(this.page)
}
async selectAll(locator?: Locator | null): Promise<void> {

View File

@@ -140,13 +140,11 @@ export class NodeOperationsHelper {
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
target
)
await this.comfyPage.nextFrame()
if (revertAfter) {
await this.comfyPage.canvasOps.dragAndDrop(
{ x: target.x - 2, y: target.y - 1 },
bottomRight
)
await this.comfyPage.nextFrame()
}
}
@@ -158,7 +156,6 @@ export class NodeOperationsHelper {
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
}
async fillPromptDialog(value: string): Promise<void> {
@@ -192,7 +189,6 @@ export class NodeOperationsHelper {
y: 300
}
)
await this.comfyPage.nextFrame()
}
async adjustEmptyLatentWidth(): Promise<void> {

View File

@@ -1,5 +1,7 @@
import type { Page } from '@playwright/test'
import { nextFrame } from '@e2e/fixtures/utils/timing'
export class SettingsHelper {
constructor(private readonly page: Page) {}
@@ -10,6 +12,7 @@ export class SettingsHelper {
},
{ id: settingId, value: settingValue }
)
await nextFrame(this.page)
}
async getSetting<T = unknown>(settingId: string): Promise<T> {

View File

@@ -465,11 +465,7 @@ export class SubgraphHelper {
const serialized = await this.page.evaluate(() =>
window.app!.graph!.serialize()
)
await this.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized as ComfyWorkflowJSON
)
await this.comfyPage.nextFrame()
await this.comfyPage.workflow.loadGraphData(serialized as ComfyWorkflowJSON)
}
async convertDefaultKSamplerToSubgraph(): Promise<NodeReference> {
@@ -477,14 +473,12 @@ export class SubgraphHelper {
const ksampler = await this.comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await this.comfyPage.nextFrame()
return subgraphNode
}
async packAllInteriorNodes(hostNodeId: string): Promise<void> {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,

View File

@@ -70,10 +70,19 @@ export class WorkflowHelper {
)
}
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
await this.comfyPage.page.evaluate(
(wf) => window.app!.loadGraphData(wf),
workflow
)
await this.comfyPage.nextFrame()
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)
)
await this.waitForWorkflowIdle()
await this.comfyPage.nextFrame()
if (test.info().tags.includes('@vue-nodes')) {
await this.comfyPage.vueNodes.waitForNodes()

View File

@@ -0,0 +1,32 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { ResultItemType } from '@/schemas/apiSchema'
export type ImportedAssetFixture = {
name: string
filePath?: string
contentType?: string
}
export type GeneratedOutputFixture = {
filename: string
displayName?: string
filePath?: string
contentType?: string
mediaType?: 'images' | 'video' | 'audio'
subfolder?: string
type?: ResultItemType
}
export type GeneratedJobFixture = {
jobId: string
status?: JobEntry['status']
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
createdAt?: string
createTime?: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string
workflow?: JobDetailResponse['workflow']
nodeId?: string
}

View File

@@ -0,0 +1,34 @@
import type { JobDetailResponse } from '@comfyorg/ingest-types'
import type { TaskOutput } from '@/schemas/apiSchema'
import type {
GeneratedJobFixture,
GeneratedOutputFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
export function buildMockJobOutputs(
job: GeneratedJobFixture,
outputs: GeneratedOutputFixture[]
): NonNullable<JobDetailResponse['outputs']> {
const nodeId = job.nodeId ?? '5'
const nodeOutputs: Pick<TaskOutput[string], 'audio' | 'images' | 'video'> = {}
for (const output of outputs) {
const mediaType = output.mediaType ?? 'images'
nodeOutputs[mediaType] = [
...(nodeOutputs[mediaType] ?? []),
{
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
display_name: output.displayName
}
]
}
const taskOutput = { [nodeId]: nodeOutputs } satisfies TaskOutput
return taskOutput
}

View File

@@ -0,0 +1,76 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
export function createMockJobs(
count: number,
baseOverrides?: Partial<JobEntry>
): JobEntry[] {
const now = Date.now()
return Array.from({ length: count }, (_, index) =>
createMockJob({
id: `job-${String(index + 1).padStart(3, '0')}`,
create_time: now - index * 60_000,
execution_start_time: now - index * 60_000,
execution_end_time: now - index * 60_000 + (5 + index) * 1_000,
preview_output: {
filename: `image_${String(index + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createMockJobRecords(
listItems: readonly JobEntry[]
): MockJobRecord[] {
return listItems.map(createMockJobRecord)
}

View File

@@ -0,0 +1,140 @@
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import type {
GeneratedJobFixture,
GeneratedOutputFixture,
ImportedAssetFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
const helperDir = path.dirname(fileURLToPath(import.meta.url))
export type MockAssetFile = {
filePath?: string
contentType?: string
textContent?: string
}
export type MockFileLocation = {
filename: string
type: string
subfolder: string
}
function getFixturePath(relativePath: string): string {
return path.resolve(helperDir, '../../assets', relativePath)
}
export function buildFileRequestKey({
filename,
type,
subfolder
}: MockFileLocation): string {
return new URLSearchParams({
filename,
type,
subfolder
}).toString()
}
export function defaultFileFor(filename: string): MockAssetFile {
const normalized = filename.toLowerCase()
if (normalized.endsWith('.png')) {
return {
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
contentType: 'image/png'
}
}
if (normalized.endsWith('.webp')) {
return {
filePath: getFixturePath('example.webp'),
contentType: 'image/webp'
}
}
if (normalized.endsWith('.webm')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.webm'),
contentType: 'video/webm'
}
}
if (normalized.endsWith('.mp4')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
contentType: 'video/mp4'
}
}
if (normalized.endsWith('.glb')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.glb'),
contentType: 'model/gltf-binary'
}
}
if (normalized.endsWith('.json')) {
return {
textContent: JSON.stringify({ mocked: true }, null, 2),
contentType: 'application/json'
}
}
return {
textContent: 'mocked asset content',
contentType: getMimeType(filename)
}
}
function outputLocation(output: GeneratedOutputFixture): MockFileLocation {
return {
filename: output.filename,
type: output.type ?? 'output',
subfolder: output.subfolder ?? ''
}
}
function importedAssetLocation(asset: ImportedAssetFixture): MockFileLocation {
return {
filename: asset.name,
type: 'input',
subfolder: ''
}
}
export function buildMockAssetFiles({
generated,
imported
}: {
generated: readonly GeneratedJobFixture[]
imported: readonly ImportedAssetFixture[]
}): Map<string, MockAssetFile> {
const mockFiles = new Map<string, MockAssetFile>()
for (const job of generated) {
for (const output of job.outputs) {
const fallback = defaultFileFor(output.filename)
mockFiles.set(buildFileRequestKey(outputLocation(output)), {
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
}
for (const asset of imported) {
const fallback = defaultFileFor(asset.name)
mockFiles.set(buildFileRequestKey(importedAssetLocation(asset)), {
filePath: asset.filePath ?? fallback.filePath,
contentType: asset.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
return mockFiles
}

View File

@@ -0,0 +1,15 @@
import { test as base } from '@playwright/test'
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
export const jobsApiMockFixture = base.extend<{
jobsApi: JobsApiMock
}>({
jobsApi: async ({ page }, use) => {
const jobsApi = new JobsApiMock(page)
await use(jobsApi)
await jobsApi.clear()
}
})

View File

@@ -198,6 +198,16 @@ export const TestIds = {
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
batchNav: 'batch-nav',
beforeBatch: 'before-batch',
afterBatch: 'after-batch',
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
}
} as const
@@ -231,3 +241,4 @@ export type TestIdValue =
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
| (typeof TestIds.loading)[keyof typeof TestIds.loading]
| (typeof TestIds.load3dViewer)[keyof typeof TestIds.load3dViewer]
| (typeof TestIds.imageCompare)[keyof typeof TestIds.imageCompare]

View File

@@ -388,7 +388,6 @@ export class NodeReference {
async copy() {
await this.click('title')
await this.comfyPage.clipboard.copy()
await this.comfyPage.nextFrame()
}
async delete(): Promise<void> {
await this.click('title')
@@ -434,7 +433,6 @@ export class NodeReference {
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)

View File

@@ -1,3 +1,9 @@
import type { Page } from '@playwright/test'
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function nextFrame(page: Page): Promise<number> {
return page.evaluate(() => new Promise<number>(requestAnimationFrame))
}

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
@@ -17,6 +18,8 @@ import {
STABLE_OUTPUT
} from '@e2e/fixtures/data/assetFixtures'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets respects pagination params', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets filters by include_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('PUT /assets/:id updates asset in store', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({ comfyPage }) => {
test('POST /assets returns upload response', async ({
comfyPage,
assetApi
}) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('tracks POST, PUT, DELETE mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET requests are not tracked as mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('returns error status for all asset routes', async ({
comfyPage,
assetApi
}) => {
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()

View File

@@ -157,7 +157,10 @@ test.describe('Builder input reordering', { tag: '@ui' }, () => {
test('Reordering inputs in one app does not corrupt another app', async ({
comfyPage
}) => {
}, testInfo) => {
// This test creates 2 apps, switches tabs 3 times, and enters builder 3
// times — the default 15s timeout is insufficient in CI.
testInfo.setTimeout(45_000)
const { appMode } = comfyPage
const app2Widgets = ['seed', 'steps']
const app1Reordered = ['steps', 'cfg', 'seed']

View File

@@ -110,8 +110,7 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
await expect(comfyPage.appMode.steps.toolbar).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect(comfyPage.appMode.steps.toolbar).toBeHidden()
})

View File

@@ -29,7 +29,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
await comfyPage.nextFrame()
})
test.describe('Trigger button', () => {
@@ -46,7 +45,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
await expect(modeIcon).toHaveClass(mode.iconPattern)
@@ -103,7 +101,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
}) => {
if (!mode.isReadOnly) {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
}
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
@@ -156,7 +153,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -208,7 +204,6 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand(mode.activateCommand)
await comfyPage.nextFrame()
const { trigger, menu, selectItem, handItem } = getLocators(
comfyPage.page
)
@@ -229,8 +224,7 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts unlocked'
).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyH')
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
@@ -241,13 +235,11 @@ test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(
await comfyPage.canvasOps.isReadOnly(),
'Precondition: canvas starts locked'
).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyV')
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
const { trigger } = getLocators(comfyPage.page)
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()

View File

@@ -223,8 +223,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(node).toBePinned()
await afterChange(comfyPage)
}

View File

@@ -74,18 +74,23 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
return node!.id
})
// Wait for the asset widget to mount AND its value to resolve.
// The widget type becomes 'asset' before the value is populated,
// so poll for both conditions together to avoid a race where the
// type check passes but the value is still the placeholder.
await expect
.poll(
async () => {
return await comfyPage.page.evaluate((id) => {
() =>
comfyPage.page.evaluate((id) => {
const node = window.app!.graph.getNodeById(id)
const widget = node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
}, nodeId)
},
{ timeout: 10_000 }
if (widget?.type !== 'asset') return 'waiting:type'
const val = String(widget?.value ?? '')
return val === 'Select model' ? 'waiting:value' : val
}, nodeId),
{ timeout: 15_000 }
)
.toBe(CLOUD_ASSETS[0].name)
})

View File

@@ -157,18 +157,15 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await comfyPage.workflow.loadWorkflow('nodes/every_node_color')
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark-all-colors.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light_red')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-light-red.png'
)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
@@ -181,7 +178,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
await expect(comfyPage.toast.toastErrors).toHaveCount(0)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -190,7 +186,6 @@ test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
'Comfy.ColorPalette',
'custom_obsidian_dark'
)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
@@ -212,15 +207,12 @@ test.describe(
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-0.5.png')
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.mouse.move(8, 8)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
@@ -229,8 +221,8 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.page.mouse.move(0, 0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'node-opacity-0.2-arc-theme.png'
)
})
@@ -240,7 +232,6 @@ test.describe(
}) => {
await comfyPage.settings.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
@@ -279,7 +270,6 @@ test.describe(
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-lightened-colors.png'
)

View File

@@ -155,7 +155,6 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
await comfyPage.nextFrame()
const uploadPromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,

View File

@@ -52,8 +52,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+Equal')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -63,8 +62,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+Minus')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -82,8 +80,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Period')
await expect
.poll(() => comfyPage.canvasOps.getScale())
@@ -93,8 +90,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'h' locks canvas", async ({ comfyPage }) => {
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyH')
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
})
@@ -102,11 +98,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyV')
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
})
@@ -121,16 +115,13 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
await expect.poll(() => node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+KeyC')
await expect.poll(() => node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+KeyC')
await expect.poll(() => node.isCollapsed()).toBe(false)
})
@@ -140,7 +131,6 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
@@ -150,13 +140,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await expect.poll(() => getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+KeyM')
// NEVER (2) = muted
await expect.poll(() => getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+KeyM')
await expect.poll(() => getMode()).toBe(0)
})
})
@@ -239,16 +227,14 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+s')
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible()
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
@@ -265,8 +251,7 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+o')
await expect
.poll(() => comfyPage.page.evaluate(() => window.TestCommand))
@@ -288,11 +273,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Shift+KeyE')
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)

View File

@@ -145,15 +145,27 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
const settingRow = dialog.root.locator(`[data-setting-id="${settingId}"]`)
await expect(settingRow).toBeVisible()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may re-render during search
// filtering, causing the first click to land on a stale element.
// Wait for the search filter to fully settle — PrimeVue re-renders
// the entire settings list after typing, and the combobox element is
// replaced during re-render. Wait until the filtered list stabilises
// before interacting with the combobox.
const settingItems = dialog.root.locator('[data-setting-id]')
await expect
.poll(() => settingItems.count(), { timeout: 5000 })
.toBeLessThanOrEqual(5)
const select = settingRow.getByRole('combobox')
await expect(select).toBeVisible()
await expect(select).toBeEnabled()
// Open the dropdown via its combobox role and verify it expanded.
// Retry because the PrimeVue Select may still re-render after the
// filter settles, causing the first click to land on a stale element.
await expect(async () => {
const expanded = await select.getAttribute('aria-expanded')
if (expanded !== 'true') await select.click()
await expect(select).toHaveAttribute('aria-expanded', 'true')
}).toPass({ timeout: 5000 })
}).toPass({ timeout: 10_000 })
// Pick the option that is not the current value
const targetValue = initialValue === 'Top' ? 'Disabled' : 'Top'

View File

@@ -24,8 +24,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
TestIds.canvas.toggleLinkVisibilityButton
)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'canvas-with-hidden-links.png'
)
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
@@ -36,8 +36,8 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
.toBe(hiddenLinkRenderMode)
await button.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'canvas-with-visible-links.png'
)
await expect

View File

@@ -170,7 +170,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
await comfyPage.workflow.loadWorkflow(
'groupnodes/group_node_identical_nodes_hidden_inputs'
)
await comfyPage.nextFrame()
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
@@ -336,12 +335,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) =>
window.app!.loadGraphData(workflow as ComfyWorkflowJSON),
currentGraphState
await comfyPage.workflow.loadGraphData(
currentGraphState as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
await verifyNodeLoaded(comfyPage, 1)
})
})

View File

@@ -60,7 +60,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -84,7 +83,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
false
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')
await comfyPage.canvas.click({ position: outerPos })
@@ -107,7 +105,6 @@ test.describe('Group Select Children', { tag: ['@canvas', '@node'] }, () => {
true
)
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.nextFrame()
// Select the outer group (cascades to children)
const outerPos = await getGroupTitlePosition(comfyPage, 'Outer Group')

View File

@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -87,10 +88,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
test(
'Shows empty state when no images are set',
{ tag: '@smoke' },
@@ -98,7 +95,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByTestId('image-compare-empty')).toBeVisible()
await expect(node.getByTestId(TestIds.imageCompare.empty)).toBeVisible()
await expect(node.locator('img')).toHaveCount(0)
await expect(node.getByRole('presentation')).toHaveCount(0)
}
@@ -126,10 +123,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider defaults
// ---------------------------------------------------------------------------
test(
'Slider defaults to 50% with both images set',
{ tag: ['@smoke', '@screenshot'] },
@@ -164,10 +157,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
}
)
// ---------------------------------------------------------------------------
// Slider interaction
// ---------------------------------------------------------------------------
test(
'Mouse hover moves slider position',
{ tag: '@smoke' },
@@ -183,7 +172,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const handle = node.getByRole('presentation')
const beforeImg = node.locator('img[alt="Before image"]')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -224,7 +213,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const afterImg = node.locator('img[alt="After image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await expect(afterImg).toBeVisible()
await expect(viewport).toBeVisible()
@@ -261,7 +250,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const handle = node.getByRole('presentation')
const compareArea = node.getByTestId('image-compare-viewport')
const compareArea = node.getByTestId(TestIds.imageCompare.viewport)
await expect(compareArea).toBeVisible()
await expect
@@ -292,10 +281,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
.toBeCloseTo(100, 0)
})
// ---------------------------------------------------------------------------
// Single image modes
// ---------------------------------------------------------------------------
test('Only before image shows without slider when afterImages is empty', async ({
comfyPage
}) => {
@@ -324,10 +309,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(node.getByRole('presentation')).toBeHidden()
})
// ---------------------------------------------------------------------------
// Batch navigation
// ---------------------------------------------------------------------------
test(
'Batch navigation appears when before side has multiple images',
{ tag: '@smoke' },
@@ -342,13 +323,21 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
await expect(node.getByTestId('batch-nav')).toBeVisible()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
await expect(
node.getByTestId(TestIds.imageCompare.batchNav)
).toBeVisible()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 3')
// after-batch renders only when afterBatchCount > 1
await expect(node.getByTestId('after-batch')).toBeHidden()
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
await expect(
node.getByTestId(TestIds.imageCompare.afterBatch)
).toBeHidden()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeDisabled()
}
)
@@ -362,7 +351,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.getByTestId('batch-nav')).toBeHidden()
await expect(node.getByTestId(TestIds.imageCompare.batchNav)).toBeHidden()
})
test(
@@ -378,10 +367,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
await nextBtn.click()
await expect(counter).toHaveText('2 / 3')
@@ -407,10 +396,10 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const counter = beforeBatch.getByTestId('batch-counter')
const nextBtn = beforeBatch.getByTestId('batch-next')
const prevBtn = beforeBatch.getByTestId('batch-prev')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const counter = beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
const nextBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const prevBtn = beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
await nextBtn.click()
await nextBtn.click()
@@ -436,14 +425,18 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
await beforeBatch.getByTestId('batch-next').click()
await afterBatch.getByTestId('batch-next').click()
await beforeBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await afterBatch.getByTestId(TestIds.imageCompare.batchNext).click()
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 3')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
'src',
url2
@@ -454,11 +447,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
})
// ---------------------------------------------------------------------------
// Node sizing
// ---------------------------------------------------------------------------
test('ImageCompare node enforces minimum size', async ({ comfyPage }) => {
const minWidth = 400
const minHeight = 350
const size = await comfyPage.page.evaluate(() => {
const graphNode = window.app!.graph.getNodeById(1)
if (!graphNode?.size) return null
@@ -472,17 +463,13 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(
size.width,
'ImageCompare node minimum width'
).toBeGreaterThanOrEqual(400)
).toBeGreaterThanOrEqual(minWidth)
expect(
size.height,
'ImageCompare node minimum height'
).toBeGreaterThanOrEqual(350)
).toBeGreaterThanOrEqual(minHeight)
})
// ---------------------------------------------------------------------------
// Visual regression screenshots
// ---------------------------------------------------------------------------
for (const { pct, expectedClipMin, expectedClipMax } of [
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
@@ -500,7 +487,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeImg = node.locator('img[alt="Before image"]')
const viewport = node.getByTestId('image-compare-viewport')
const viewport = node.getByTestId(TestIds.imageCompare.viewport)
await waitForImagesLoaded(node)
await expect(viewport).toBeVisible()
await moveToPercentage(comfyPage.page, viewport, pct)
@@ -516,10 +503,6 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
)
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
test('Widget handles image load failure gracefully', async ({
comfyPage
}) => {
@@ -586,9 +569,14 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByTestId('before-batch').getByTestId('batch-next').click()
await node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchNext)
.click()
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('2 / 2')
await setImageCompareValue(comfyPage, {
@@ -601,7 +589,9 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
green1Url
)
await expect(
node.getByTestId('before-batch').getByTestId('batch-counter')
node
.getByTestId(TestIds.imageCompare.beforeBatch)
.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 2')
})
@@ -656,23 +646,35 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const beforeBatch = node.getByTestId('before-batch')
const afterBatch = node.getByTestId('after-batch')
const beforeBatch = node.getByTestId(TestIds.imageCompare.beforeBatch)
const afterBatch = node.getByTestId(TestIds.imageCompare.afterBatch)
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('1 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('1 / 20')
const beforeNext = beforeBatch.getByTestId('batch-next')
const afterNext = afterBatch.getByTestId('batch-next')
const beforeNext = beforeBatch.getByTestId(TestIds.imageCompare.batchNext)
const afterNext = afterBatch.getByTestId(TestIds.imageCompare.batchNext)
for (let i = 0; i < 19; i++) {
await beforeNext.click()
await afterNext.click()
}
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('20 / 20')
await expect(beforeBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(afterBatch.getByTestId('batch-prev')).toBeEnabled()
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchCounter)
).toHaveText('20 / 20')
await expect(
beforeBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(
afterBatch.getByTestId(TestIds.imageCompare.batchPrev)
).toBeEnabled()
await expect(beforeNext).toBeDisabled()
await expect(afterNext).toBeDisabled()
})

View File

@@ -31,11 +31,9 @@ test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
await comfyPage.canvas.press('KeyP')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('KeyP')
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
})
})
@@ -76,13 +74,11 @@ test.describe('Node Interaction', () => {
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png')
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode2
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png')
}
)
@@ -174,8 +170,7 @@ test.describe('Node Interaction', () => {
await comfyPage.nodeOps.dragTextEncodeNode2()
// Move mouse away to avoid hover highlight on the node at the drop position.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png', {
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
maxDiffPixels: 50
})
})
@@ -185,7 +180,6 @@ test.describe('Node Interaction', () => {
// Pin this suite to the legacy canvas path so Alt+drag exercises
// LGraphCanvas, not the Vue node drag handler.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
})
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
@@ -285,7 +279,6 @@ test.describe('Node Interaction', () => {
}) => {
await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot)
await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot)
@@ -359,8 +352,8 @@ test.describe('Node Interaction', () => {
modifiers: ['Control', 'Alt'],
position: loadCheckpointClipSlotPos
})
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'batch-disconnect-links-disconnected.png'
)
}
@@ -410,8 +403,8 @@ test.describe('Node Interaction', () => {
await expect.poll(() => targetNode.isCollapsed()).toBe(false)
// Move mouse away to avoid hover highlight differences.
await comfyPage.canvasOps.moveMouseToEmptyArea()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'text-encode-toggled-back-open.png'
)
}
@@ -514,8 +507,7 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
// Confirm group title
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot(
'group-selected-nodes.png'
)
@@ -1171,8 +1163,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-middle-drag-pan.png'
)
})
@@ -1180,14 +1172,14 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-wheel-zoom-in.png'
)
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'legacy-wheel-zoom-out.png'
)
})
@@ -1247,8 +1239,8 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.page.mouse.move(150, 150)
await comfyPage.page.mouse.up({ button: 'middle' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-middle-drag-pan.png'
)
})
@@ -1258,16 +1250,16 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, -120)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-ctrl-wheel-zoom-in.png'
)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.wheel(0, 240)
await comfyPage.page.keyboard.up('Control')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-ctrl-wheel-zoom-out.png'
)
})
@@ -1359,33 +1351,31 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
)
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-right.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, -240)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-left.png'
)
await comfyPage.page.keyboard.down('Shift')
await comfyPage.page.mouse.wheel(0, 120)
await comfyPage.page.keyboard.up('Shift')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'standard-shift-wheel-pan-center.png'
)
})

View File

@@ -112,9 +112,8 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
await comfyPage.expectScreenshot(
load3d.node,
'load3d-uploaded-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)
@@ -142,9 +141,8 @@ test.describe('Load3D', () => {
await expect.poll(() => modelFileWidget.getValue()).toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
await expect(load3d.node).toHaveScreenshot(
await comfyPage.expectScreenshot(
load3d.node,
'load3d-dropped-cube-obj.png',
{ maxDiffPixelRatio: 0.1 }
)

View File

@@ -143,8 +143,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
}
)

View File

@@ -11,8 +11,10 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'mobile-empty-canvas.png'
)
})
test('@mobile default workflow', async ({ comfyPage }) => {
@@ -24,7 +26,6 @@ test.describe(
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.nextFrame()
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
@@ -38,9 +39,8 @@ test.describe(
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.settingDialog.root,
'mobile-settings-dialog.png',
{
mask: [

View File

@@ -13,7 +13,6 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
@@ -35,7 +34,6 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -14,7 +14,6 @@ async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
async function addGhostAtCenter(comfyPage: ComfyPage) {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -53,7 +52,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
test('positions ghost node at cursor', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
const viewport = comfyPage.page.viewportSize()!
const centerX = Math.round(viewport.width / 2)
@@ -110,8 +108,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -124,8 +121,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Delete')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()
@@ -138,8 +134,7 @@ for (const mode of ['litegraph', 'vue'] as const) {
expect(before).not.toBeNull()
expect(before!.ghost).toBe(true)
await comfyPage.page.keyboard.press('Backspace')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Backspace')
const after = await getNodeById(comfyPage, nodeId)
expect(after).toBeNull()

View File

@@ -303,8 +303,8 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'CLIP | CLIP'
)
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'link-release-context-menu.png'
)
}

View File

@@ -1,13 +1,19 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/helpers/jobFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
const now = Date.now()
const MOCK_JOBS: RawJobListItem[] = [
const MOCK_JOBS: JobEntry[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
@@ -35,16 +41,14 @@ const MOCK_JOBS: RawJobListItem[] = [
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
await comfyPage.setupSettings({
'Comfy.Queue.QPOV2': false
})
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()

View File

@@ -19,8 +19,10 @@ test.describe(
await comfyPage.page.getByText('loaders').click()
await comfyPage.page.getByText('Load VAE').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-node-node-added.png'
)
})
test('Can add group', async ({ comfyPage }) => {
@@ -28,8 +30,8 @@ test.describe(
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Group', { exact: true }).click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'add-group-group-added.png'
)
})
@@ -45,8 +47,8 @@ test.describe(
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
@@ -60,12 +62,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-properties-panel.png'
)
})
@@ -76,12 +77,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-collapsed.png'
)
})
@@ -104,8 +104,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.getByText('Collapse').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-collapsed-badge.png'
)
})
@@ -116,12 +116,11 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.getByText('Bypass').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-bypassed.png'
)
})
@@ -133,8 +132,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.expectScreenshot(comfyPage.canvas, 'right-click-node.png')
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
@@ -149,8 +147,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-pinned-node.png'
)
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
@@ -160,8 +158,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
button: 'right'
})
await comfyPage.page.mouse.move(10, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-unpinned-node.png'
)
})
@@ -206,8 +204,10 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.page.locator('.litemenu-entry:has-text("Pin")').click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('selected-nodes-pinned.png')
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-pinned.png'
)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'
@@ -216,8 +216,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await comfyPage.nextFrame()
await comfyPage.page.locator('.litemenu-entry:has-text("Unpin")').click()
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'selected-nodes-unpinned.png'
)
})

View File

@@ -11,15 +11,13 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
const totalCount = await comfyPage.vueNodes.getNodeCount()
// Use canvas press for keyboard shortcuts (doesn't need click target)
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect(comfyPage.vueNodes.selectedNodes).not.toHaveCount(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
@@ -70,8 +68,7 @@ test.describe('@canvas Selection Rectangle', { tag: '@vue-nodes' }, () => {
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+a')
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())

View File

@@ -267,8 +267,7 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
.click()
// Undo the colorization
await comfyPage.page.keyboard.press('Control+Z')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Control+Z')
// Node should be uncolored again
const selectedNode = (

View File

@@ -32,7 +32,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
test('delete button removes selected node', async ({ comfyPage }) => {
@@ -69,7 +68,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -83,7 +81,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
@@ -160,7 +157,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -187,7 +183,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
@@ -229,7 +224,6 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (

View File

@@ -14,7 +14,6 @@ test.describe(
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await comfyPage.nodeOps.selectNodes(['KSampler'])
await comfyPage.nextFrame()
})
@@ -43,7 +42,6 @@ test.describe(
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()

View File

@@ -1,22 +1,21 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import {
createMockJob,
createMockJobs
} from '@e2e/fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
} from '@e2e/fixtures/helpers/jobFixtures'
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const SAMPLE_JOBS: RawJobListItem[] = [
const SAMPLE_JOBS: JobEntry[] = [
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'landscape.png',
subfolder: '',
@@ -28,9 +27,9 @@ const SAMPLE_JOBS: RawJobListItem[] = [
}),
createMockJob({
id: 'job-beta',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2003,
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_003_000,
preview_output: {
filename: 'portrait.png',
subfolder: '',
@@ -42,9 +41,9 @@ const SAMPLE_JOBS: RawJobListItem[] = [
}),
createMockJob({
id: 'job-gamma',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3020,
create_time: 3_000_000,
execution_start_time: 3_000_000,
execution_end_time: 3_020_000,
preview_output: {
filename: 'abstract_art.png',
subfolder: '',
@@ -62,20 +61,12 @@ const SAMPLE_IMPORTED_FILES = [
'audio_clip.wav'
]
// ==========================================================================
// 1. Empty states
// ==========================================================================
test.describe('Assets sidebar - empty states', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
@@ -101,21 +92,13 @@ test.describe('Assets sidebar - empty states', () => {
})
})
// ==========================================================================
// 2. Tab navigation
// ==========================================================================
test.describe('Assets sidebar - tab navigation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Generated tab is active by default', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
@@ -130,12 +113,10 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
@@ -144,31 +125,21 @@ test.describe('Assets sidebar - tab navigation', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
})
// ==========================================================================
// 3. Asset display - grid view
// ==========================================================================
test.describe('Assets sidebar - grid view display', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Displays generated assets as cards in grid view', async ({
comfyPage
}) => {
@@ -193,8 +164,8 @@ test.describe('Assets sidebar - grid view display', () => {
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
test('Displays svg outputs', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
test('Displays svg outputs', async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory([
createMockJob({
id: 'job-alpha',
create_time: 1000,
@@ -218,31 +189,22 @@ test.describe('Assets sidebar - grid view display', () => {
})
})
// ==========================================================================
// 4. View mode toggle (grid <-> list)
// ==========================================================================
test.describe('Assets sidebar - view mode toggle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.assetCards).toHaveCount(0)
await expect(tab.listViewItems.first()).toBeVisible()
})
@@ -251,12 +213,10 @@ test.describe('Assets sidebar - view mode toggle', () => {
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
// Switch back to grid view (settings popover is still open)
await tab.gridViewOption.click()
await tab.waitForAssets()
@@ -265,21 +225,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
})
})
// ==========================================================================
// 5. Search functionality
// ==========================================================================
test.describe('Assets sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Search input is visible', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
@@ -296,7 +248,6 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
@@ -310,7 +261,6 @@ test.describe('Assets sidebar - search', () => {
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
@@ -328,30 +278,20 @@ test.describe('Assets sidebar - search', () => {
})
})
// ==========================================================================
// 6. Asset selection
// ==========================================================================
test.describe('Assets sidebar - selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
@@ -363,11 +303,9 @@ test.describe('Assets sidebar - selection', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
@@ -379,7 +317,6 @@ test.describe('Assets sidebar - selection', () => {
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
@@ -391,15 +328,12 @@ test.describe('Assets sidebar - selection', () => {
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible()
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
@@ -409,44 +343,31 @@ test.describe('Assets sidebar - selection', () => {
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
})
})
// ==========================================================================
// 7. Context menu
// ==========================================================================
test.describe('Assets sidebar - context menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
})
@@ -539,23 +460,17 @@ test.describe('Assets sidebar - context menu', () => {
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
// Dismiss any toasts that appeared after asset loading
await tab.dismissToasts()
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
// the modifier — click({ modifiers }) only sets the mouse event flag and
// does not fire a keydown event that VueUse tracks.
await cards.first().click()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('ControlOrMeta')
await cards.nth(1).click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('ControlOrMeta')
// Verify multi-selection took effect and footer is stable before right-clicking
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionFooter).toBeVisible()
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
// overlay intercepting the event, and assert directly without toPass.
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
@@ -564,26 +479,17 @@ test.describe('Assets sidebar - context menu', () => {
})
await expect(contextMenu).toBeVisible()
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
// ==========================================================================
// 8. Bulk actions (footer)
// ==========================================================================
test.describe('Assets sidebar - bulk actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Footer shows download button when assets selected', async ({
comfyPage
}) => {
@@ -615,17 +521,14 @@ test.describe('Assets sidebar - bulk actions', () => {
await tab.open()
await tab.waitForAssets()
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
// outputs_count: 2 which would inflate the total.
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
await cards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('ControlOrMeta')
await cards.nth(2).click()
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('ControlOrMeta')
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible()
@@ -633,84 +536,13 @@ test.describe('Assets sidebar - bulk actions', () => {
})
})
// ==========================================================================
// 9. Pagination
// ==========================================================================
test.describe('Assets sidebar - pagination', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('initial load fetches first batch with offset 0', async ({
comfyPage
}) => {
const manyJobs = createMockJobs(250)
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
// Capture the first history fetch (terminal statuses only).
// Queue polling also hits /jobs but with status=in_progress,pending.
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
const status = url.searchParams.get('status') ?? ''
return status.includes('completed')
})
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const req = await firstRequest
const url = new URL(req.url())
expect(url.searchParams.get('offset')).toBe('0')
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
})
})
// ==========================================================================
// 10. Settings menu visibility
// ==========================================================================
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})
// ==========================================================================
// 11. Delete confirmation
// ==========================================================================
test.describe('Assets sidebar - delete confirmation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockDeleteHistory()
await comfyPage.assets.mockInputFiles([])
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-click delete shows confirmation dialog', async ({
comfyPage
}) => {
@@ -744,7 +576,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await comfyPage.confirmDialog.delete.click()
await comfyPage.confirmDialog.click('delete')
await expect(dialog).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount - 1)
@@ -766,9 +598,54 @@ test.describe('Assets sidebar - delete confirmation', () => {
const dialog = comfyPage.confirmDialog.root
await expect(dialog).toBeVisible()
await comfyPage.confirmDialog.reject.click()
await comfyPage.confirmDialog.click('reject')
await expect(dialog).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount)
})
})
test.describe('Assets sidebar - pagination', () => {
test('initial load fetches first batch with offset 0', async ({
comfyPage,
assetScenario
}) => {
const manyJobs = createMockJobs(250)
await assetScenario.mockGeneratedHistory(manyJobs)
await comfyPage.setup()
const firstRequest = comfyPage.page.waitForRequest((req) => {
if (!/\/api\/jobs\?/.test(req.url())) return false
const url = new URL(req.url())
const status = url.searchParams.get('status') ?? ''
return status.includes('completed')
})
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const req = await firstRequest
const url = new URL(req.url())
expect(url.searchParams.get('offset')).toBe('0')
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
})
})
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(SAMPLE_JOBS)
await assetScenario.mockImportedFiles([])
await comfyPage.setup()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})

View File

@@ -0,0 +1,149 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const GENERATED_JOBS: JobEntry[] = [
createMockJob({
id: 'job-alpha',
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-beta',
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_008_000,
preview_output: {
filename: 'portrait.webp',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gamma',
create_time: 3_000_000,
execution_start_time: 3_000_000,
execution_end_time: 3_015_000,
preview_output: {
filename: 'gallery.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
]
test.describe('Assets sidebar actions', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(GENERATED_JOBS)
await comfyPage.setup()
})
test('shows selection footer actions after selecting an asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.asset('gallery.png').click()
await expect(tab.selectionFooter).toBeVisible()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.downloadSelectedButton).toBeVisible()
await expect(tab.deleteSelectedButton).toBeVisible()
})
test('supports multi-select and deselect all', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.selectAssets(['landscape.png', 'portrait.webp'])
await expect(tab.selectedCards).toHaveCount(2)
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible()
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
test('shows the output asset context menu actions', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.openContextMenuForAsset('landscape.png')
await expect(tab.contextMenuAction('Download')).toBeVisible()
await expect(tab.contextMenuAction('Inspect asset')).toBeVisible()
await expect(tab.contextMenuAction('Delete')).toBeVisible()
await expect(tab.contextMenuAction('Copy job ID')).toBeVisible()
})
test('shows the bulk context menu for multi-selection', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.selectAssets(['landscape.png', 'portrait.webp'])
await expect(tab.selectionFooter).toBeVisible()
await tab.asset('landscape.png').dispatchEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
await expect(tab.contextMenuAction('Download all')).toBeVisible()
})
test('confirms delete and removes the selected asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
await tab.runContextMenuAction('gallery.png', 'Delete')
await expect(comfyPage.confirmDialog.root).toBeVisible()
await expect(
comfyPage.confirmDialog.root.getByText('Delete this asset?')
).toBeVisible()
await comfyPage.confirmDialog.click('delete')
await expect(comfyPage.confirmDialog.root).toBeHidden()
await expect(tab.assetCards).toHaveCount(initialCount - 1)
await expect(
comfyPage.page.locator('.p-toast-message-success')
).toBeVisible()
})
})

View File

@@ -0,0 +1,165 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const GENERATED_JOBS: JobEntry[] = [
createMockJob({
id: 'job-landscape',
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-portrait',
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_008_000,
preview_output: {
filename: 'portrait.webp',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gallery',
create_time: 3_000_000,
execution_start_time: 3_000_000,
execution_end_time: 3_015_000,
preview_output: {
filename: 'gallery.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 3
})
]
const IMPORTED_FILES = ['reference_photo.png', 'background.jpg', 'notes.txt']
test.describe('Assets sidebar browsing', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(GENERATED_JOBS)
await assetScenario.mockImportedFiles(IMPORTED_FILES)
await comfyPage.setup()
})
test('shows mocked generated and imported assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
await tab.switchToImported()
await expect(tab.getAssetCardByName('reference_photo.png')).toBeVisible()
})
test('switches between grid and list views with mocked results', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
await tab.openSettingsMenu()
await tab.gridViewOption.click()
await tab.waitForAssets()
await expect(tab.getAssetCardByName('landscape.png')).toBeVisible()
})
test('clears search when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
test('opens folder view for multi-output jobs and returns to all assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab
.getAssetCardByName('gallery.png')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(
comfyPage.page.getByRole('button', { name: 'Copy job ID' })
).toBeVisible()
await expect(tab.getAssetCardByName('gallery-2.png')).toBeVisible()
await comfyPage.page.getByRole('button', { name: 'Copy job ID' }).click()
await expect(
comfyPage.page.locator('.p-toast-message-success')
).toBeVisible()
await tab.backToAssetsButton.click()
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
})
test('opens the preview lightbox for generated assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.getAssetCardByName('landscape.png').dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
})
})
test.describe('Assets sidebar empty states', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockEmptyState()
await comfyPage.setup()
})
test('shows empty generated state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('shows empty imported state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
})

View File

@@ -0,0 +1,137 @@
import type { Locator } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const HISTORY_JOBS: JobEntry[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'history-completed.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_005_000,
preview_output: {
filename: 'history-failed.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
})
]
async function openOverlayMenu(comfyPage: {
page: {
getByTestId(id: string): Locator
getByLabel(label: string | RegExp): Locator
}
}) {
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
await comfyPage.page
.getByLabel(/More options/i)
.first()
.click()
}
test.describe('Job history sidebar', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(HISTORY_JOBS)
await comfyPage.setupSettings({
'Comfy.Queue.QPOV2': true
})
await comfyPage.setup()
})
test('shows mocked history and filters failed jobs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.jobHistoryTab
await tab.open()
await expect(tab.jobRow('job-completed-1')).toBeVisible()
await expect(tab.jobRow('job-failed-1')).toBeVisible()
await tab.failedTab.click()
await expect(tab.jobRow('job-failed-1')).toBeVisible()
await expect(tab.jobRow('job-completed-1')).toBeHidden()
})
test('opens the preview lightbox for completed jobs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.jobHistoryTab
await tab.open()
await tab.jobRow('job-completed-1').dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
})
test('clears history from the docked sidebar', async ({ comfyPage }) => {
const tab = comfyPage.menu.jobHistoryTab
await tab.open()
await tab.moreOptionsButton.click()
await comfyPage.page.getByTestId('clear-history-action').click()
await expect(comfyPage.confirmDialog.root).toBeVisible()
await comfyPage.confirmDialog.root
.getByRole('button', { name: 'Clear' })
.click()
await expect(tab.jobRows).toHaveCount(0)
})
test('disables clear queue when there are no queued jobs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.jobHistoryTab
await tab.open()
await expect(tab.clearQueuedButton).toBeDisabled()
})
})
test.describe('Floating overlay dock to job history', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.mockGeneratedHistory(HISTORY_JOBS)
await comfyPage.setupSettings({
'Comfy.Queue.QPOV2': false
})
await comfyPage.setup()
})
test('opens the docked job history sidebar from the floating overlay', async ({
comfyPage
}) => {
await openOverlayMenu(comfyPage)
await comfyPage.page.getByTestId('docked-job-history-action').click()
await expect(comfyPage.menu.jobHistoryTab.searchInput).toBeVisible()
await expect(
comfyPage.menu.jobHistoryTab.jobRow('job-completed-1')
).toBeVisible()
})
})

View File

@@ -39,7 +39,6 @@ test.describe('Sidebar splitter width independence', () => {
location: 'left' | 'right'
) {
await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location)
await comfyPage.nextFrame()
await dismissToasts(comfyPage)
await comfyPage.menu.nodeLibraryTab.open()
}

View File

@@ -14,7 +14,6 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea

View File

@@ -37,7 +37,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
@@ -49,8 +48,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
const initialBreadcrumbText = (await breadcrumb.textContent()) ?? ''
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await comfyPage.canvas.dblclick({
position: {
@@ -64,8 +62,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Enter')
await subgraphNode.navigateIntoSubgraph()
await expect(breadcrumb).toBeVisible()
@@ -78,7 +75,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
const backButton = breadcrumb.locator('.back-button')
@@ -90,13 +86,11 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect(backButton).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect(backButton).toHaveCount(0)
@@ -106,7 +100,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
test.describe('Navigation Hotkeys', () => {
test('Navigation hotkey can be customized', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
{
@@ -135,7 +128,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -145,8 +137,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), {
message:
@@ -154,8 +145,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
})
.toBe(true)
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Alt+q')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -163,7 +153,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -183,8 +172,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.settings)
).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.settings)
@@ -192,8 +180,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
@@ -205,7 +192,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
@@ -272,7 +258,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -296,8 +281,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect
.poll(() =>
@@ -312,7 +296,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
@@ -328,10 +311,8 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect
.poll(() =>

View File

@@ -18,7 +18,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

View File

@@ -38,13 +38,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
await nodeToClone.click('title')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('ControlOrMeta+c')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+c')
await comfyPage.page.keyboard.press('ControlOrMeta+v')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('ControlOrMeta+v')
await expect
.poll(() => comfyPage.subgraph.getNodeCount())

View File

@@ -102,7 +102,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -150,7 +149,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
@@ -318,7 +316,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
@@ -403,7 +400,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
@@ -455,7 +451,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
await expect
@@ -476,7 +471,6 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')

View File

@@ -68,7 +68,6 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
@@ -88,8 +87,7 @@ test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()

View File

@@ -15,8 +15,7 @@ async function exitSubgraphAndPublish(
subgraphNode: Awaited<ReturnType<typeof createSubgraphAndNavigateInto>>,
blueprintName: string
) {
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await subgraphNode.click('title')
await comfyPage.command.executeCommand('Comfy.PublishSubgraph', {

View File

@@ -40,7 +40,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
@@ -59,7 +58,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
await expect
.poll(async () => {
@@ -73,20 +71,17 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
await comfyPage.keyboard.press('Escape')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
@@ -121,7 +116,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(workflowName)
await comfyPage.nextFrame()
const initialValues = await getPromotedHostWidgetValues(
comfyPage,

View File

@@ -424,7 +424,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await subgraphNodeRef.navigateIntoSubgraph()

View File

@@ -34,9 +34,8 @@ test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
{ message: 'All nodes should be within the visible viewport' }
)
.toBe(true)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'viewport-fits-when-saved-offscreen.png'
)
})

View File

@@ -121,8 +121,8 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-groups-create-group.png'
)
})
@@ -131,7 +131,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
await comfyPage.workflow.loadWorkflow('groups/oversized_group')
await comfyPage.keyboard.selectAll()
await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-groups-fit-to-contents.png'
)

View File

@@ -24,8 +24,8 @@ test.describe('Vue Node Bypass', { tag: '@vue-nodes' }, () => {
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-node-bypassed-state.png'
)

View File

@@ -5,17 +5,19 @@ import {
const MUTE_HOTKEY = 'Control+m'
const MUTE_OPACITY = '0.5'
const SELECTED_CLASS = /outline-node-component-outline/
test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
test(
'should allow toggling mute on a selected node with hotkey',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await comfyPage.page.getByText('Load Checkpoint').click()
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-muted-state.png'
@@ -29,12 +31,14 @@ test.describe('Vue Node Mute', { tag: '@vue-nodes' }, () => {
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.getByText('Load Checkpoint').click()
await expect(checkpointNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
await expect(ksamplerNode).toHaveClass(SELECTED_CLASS)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)

View File

@@ -1,7 +1,4 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -13,8 +10,8 @@ test.describe('Vue Reroute Node Size', { tag: '@vue-nodes' }, () => {
'reroute node visual appearance',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
await comfyPage.expectScreenshot(
comfyPage.canvas,
'vue-reroute-node-compact.png'
)
}

View File

@@ -149,7 +149,6 @@ test.describe('Workflow Persistence', () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBeGreaterThan(1)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
@@ -289,10 +288,8 @@ test.describe('Workflow Persistence', () => {
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
await comfyPage.settings.setSetting('Comfy.Locale', 'zh')
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
@@ -349,7 +346,6 @@ test.describe('Workflow Persistence', () => {
// Create B: duplicate, add a node, then save (unmodified after save)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
@@ -410,7 +406,6 @@ test.describe('Workflow Persistence', () => {
// Create B: duplicate and save
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
// Add a Note node in B to mark it as modified
@@ -487,7 +482,6 @@ test.describe('Workflow Persistence', () => {
// Create B as an unsaved workflow with a Note node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
:aria-label="$t('mediaAsset.actions.copyJobId')"
@click="copyJobId"
>
<i class="icon-[lucide--copy] text-sm"></i>
@@ -118,6 +119,8 @@
<div
v-if="hasSelection"
ref="footerRef"
role="toolbar"
:aria-label="$t('mediaAsset.selection.actions')"
class="flex h-18 w-full items-center justify-between gap-1"
>
<div class="flex-1 pl-4">
@@ -143,6 +146,7 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
@@ -150,6 +154,7 @@
</Button>
<Button
size="icon"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>

View File

@@ -0,0 +1,446 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { api } from '@/scripts/api'
import { usePainter } from './usePainter'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
}))
vi.mock('@vueuse/core', () => ({
useElementSize: vi.fn(() => ({
width: ref(512),
height: ref(512)
}))
}))
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
StrokeProcessor: vi.fn(() => ({
addPoint: vi.fn(() => []),
endStroke: vi.fn(() => [])
}))
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/updates/common/toastStore', () => {
const store = { addAlert: vi.fn() }
return { useToastStore: () => store }
})
vi.mock('@/stores/nodeOutputStore', () => {
const store = {
getNodeImageUrls: vi.fn(() => undefined),
nodeOutputs: {},
nodePreviewImages: {}
}
return { useNodeOutputStore: () => store }
})
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
fetchApi: vi.fn()
}
}))
const mockWidgets: IBaseWidget[] = []
const mockProperties: Record<string, unknown> = {}
const mockIsInputConnected = vi.fn(() => false)
const mockGetInputNode = vi.fn(() => null)
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: {
getNodeById: vi.fn(() => ({
get widgets() {
return mockWidgets
},
get properties() {
return mockProperties
},
isInputConnected: mockIsInputConnected,
getInputNode: mockGetInputNode
}))
}
}
}
}))
type PainterResult = ReturnType<typeof usePainter>
function makeWidget(name: string, value: unknown = null): IBaseWidget {
return {
name,
value,
callback: vi.fn(),
serializeValue: undefined
} as unknown as IBaseWidget
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
function mountPainter(nodeId = 'test-node', initialModelValue = '') {
let painter!: PainterResult
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursorEl = ref<HTMLElement | null>(null)
const modelValue = ref(initialModelValue)
const Wrapper = defineComponent({
setup() {
painter = usePainter(nodeId, {
canvasEl,
cursorEl,
modelValue
})
return {}
},
render() {
return null
}
})
render(Wrapper)
return { painter, canvasEl, cursorEl, modelValue }
}
describe('usePainter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
mockWidgets.length = 0
for (const key of Object.keys(mockProperties)) {
delete mockProperties[key]
}
mockIsInputConnected.mockReturnValue(false)
mockGetInputNode.mockReturnValue(null)
})
describe('syncCanvasSizeFromWidgets', () => {
it('reads width/height from widget values on initialization', () => {
mockWidgets.push(makeWidget('width', 1024), makeWidget('height', 768))
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(1024)
expect(painter.canvasHeight.value).toBe(768)
})
it('defaults to 512 when widgets are missing', () => {
const { painter } = mountPainter()
expect(painter.canvasWidth.value).toBe(512)
expect(painter.canvasHeight.value).toBe(512)
})
})
describe('restoreSettingsFromProperties', () => {
it('restores tool and brush settings from node properties on init', () => {
mockProperties.painterTool = 'eraser'
mockProperties.painterBrushSize = 42
mockProperties.painterBrushColor = '#ff0000'
mockProperties.painterBrushOpacity = 0.5
mockProperties.painterBrushHardness = 0.8
const { painter } = mountPainter()
expect(painter.tool.value).toBe('eraser')
expect(painter.brushSize.value).toBe(42)
expect(painter.brushColor.value).toBe('#ff0000')
expect(painter.brushOpacity.value).toBe(0.5)
expect(painter.brushHardness.value).toBe(0.8)
})
it('restores backgroundColor from bg_color widget', () => {
mockWidgets.push(makeWidget('bg_color', '#123456'))
const { painter } = mountPainter()
expect(painter.backgroundColor.value).toBe('#123456')
})
it('keeps defaults when no properties are stored', () => {
const { painter } = mountPainter()
expect(painter.tool.value).toBe('brush')
expect(painter.brushSize.value).toBe(20)
expect(painter.brushColor.value).toBe('#ffffff')
expect(painter.brushOpacity.value).toBe(1)
expect(painter.brushHardness.value).toBe(1)
})
})
describe('saveSettingsToProperties', () => {
it('persists tool settings to node properties when they change', async () => {
const { painter } = mountPainter()
painter.tool.value = 'eraser'
painter.brushSize.value = 50
painter.brushColor.value = '#00ff00'
painter.brushOpacity.value = 0.7
painter.brushHardness.value = 0.3
await nextTick()
expect(mockProperties.painterTool).toBe('eraser')
expect(mockProperties.painterBrushSize).toBe(50)
expect(mockProperties.painterBrushColor).toBe('#00ff00')
expect(mockProperties.painterBrushOpacity).toBe(0.7)
expect(mockProperties.painterBrushHardness).toBe(0.3)
})
})
describe('syncCanvasSizeToWidgets', () => {
it('syncs canvas dimensions to widgets when size changes', async () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
painter.canvasWidth.value = 800
painter.canvasHeight.value = 600
await nextTick()
expect(widthWidget.value).toBe(800)
expect(heightWidget.value).toBe(600)
expect(widthWidget.callback).toHaveBeenCalledWith(800)
expect(heightWidget.callback).toHaveBeenCalledWith(600)
})
})
describe('syncBackgroundColorToWidget', () => {
it('syncs background color to widget when color changes', async () => {
const bgWidget = makeWidget('bg_color', '#000000')
mockWidgets.push(bgWidget)
const { painter } = mountPainter()
painter.backgroundColor.value = '#ff00ff'
await nextTick()
expect(bgWidget.value).toBe('#ff00ff')
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
})
})
describe('updateInputImageUrl', () => {
it('sets isImageInputConnected to false when input is not connected', () => {
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(false)
expect(painter.inputImageUrl.value).toBeNull()
})
it('sets isImageInputConnected to true when input is connected', () => {
mockIsInputConnected.mockReturnValue(true)
const { painter } = mountPainter()
expect(painter.isImageInputConnected.value).toBe(true)
})
})
describe('handleInputImageLoad', () => {
it('updates canvas size and widgets from loaded image dimensions', () => {
const widthWidget = makeWidget('width', 512)
const heightWidget = makeWidget('height', 512)
mockWidgets.push(widthWidget, heightWidget)
const { painter } = mountPainter()
const fakeEvent = {
target: {
naturalWidth: 1920,
naturalHeight: 1080
}
} as unknown as Event
painter.handleInputImageLoad(fakeEvent)
expect(painter.canvasWidth.value).toBe(1920)
expect(painter.canvasHeight.value).toBe(1080)
expect(widthWidget.value).toBe(1920)
expect(heightWidget.value).toBe(1080)
})
})
describe('cursor visibility', () => {
it('sets cursorVisible to true on pointer enter', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
expect(painter.cursorVisible.value).toBe(true)
})
it('sets cursorVisible to false on pointer leave', () => {
const { painter } = mountPainter()
painter.handlePointerEnter()
painter.handlePointerLeave()
expect(painter.cursorVisible.value).toBe(false)
})
})
describe('displayBrushSize', () => {
it('scales brush size by canvas display ratio', () => {
const { painter } = mountPainter()
// canvasDisplayWidth=512, canvasWidth=512 → ratio=1
// hardness=1 → effectiveRadius = radius * 1.0
// displayBrushSize = (20/2) * 1.0 * 2 * 1 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('increases for soft brush hardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0
// hardness=0 → effectiveRadius = 10 * 1.5 = 15
// displayBrushSize = 15 * 2 * 1 = 30
expect(painter.displayBrushSize.value).toBe(30)
})
})
describe('activeHardness (via displayBrushSize)', () => {
it('returns 1 for eraser regardless of brushHardness', () => {
const { painter } = mountPainter()
painter.brushHardness.value = 0.3
painter.tool.value = 'eraser'
// eraser hardness=1 → displayBrushSize = 10 * 1.0 * 2 = 20
expect(painter.displayBrushSize.value).toBe(20)
})
it('uses brushHardness for brush tool', () => {
const { painter } = mountPainter()
painter.tool.value = 'brush'
painter.brushHardness.value = 0.5
// hardness=0.5 → scale=1.25 → 10*1.25*2 = 25
expect(painter.displayBrushSize.value).toBe(25)
})
})
describe('registerWidgetSerialization', () => {
it('attaches serializeValue to the mask widget on init', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
expect(maskWidget.serializeValue).toBeTypeOf('function')
})
})
describe('serializeValue', () => {
it('returns empty string when canvas has no strokes', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
})
it('returns existing modelValue when not dirty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
// isCanvasEmpty() is true (no strokes drawn), so returns ''
expect(result).toBe('')
})
})
describe('restoreCanvas', () => {
it('builds correct URL from modelValue on mount', () => {
const { modelValue } = mountPainter()
// Before mount, set the modelValue
// restoreCanvas is called in onMounted, so we test by observing api.apiURL calls
// With empty modelValue, restoreCanvas exits early
expect(modelValue.value).toBe('')
})
it('calls api.apiURL with parsed filename params when modelValue is set', () => {
vi.mocked(api.apiURL).mockClear()
mountPainter('test-node', 'painter/my-image.png [temp]')
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('filename=my-image.png')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('subfolder=painter')
)
expect(api.apiURL).toHaveBeenCalledWith(
expect.stringContaining('type=temp')
)
})
})
describe('handleClear', () => {
it('does not throw when canvas element is null', () => {
const { painter } = mountPainter()
expect(() => painter.handleClear()).not.toThrow()
})
})
describe('handlePointerDown', () => {
it('ignores non-primary button clicks', () => {
const { painter } = mountPainter()
const mockSetPointerCapture = vi.fn()
const event = new PointerEvent('pointerdown', {
button: 2
})
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: mockSetPointerCapture
}
})
painter.handlePointerDown(event)
expect(mockSetPointerCapture).not.toHaveBeenCalled()
})
})
describe('handlePointerUp', () => {
it('ignores non-primary button releases', () => {
const { painter } = mountPainter()
const mockReleasePointerCapture = vi.fn()
const event = {
button: 2,
target: {
releasePointerCapture: mockReleasePointerCapture
}
} as unknown as PointerEvent
painter.handlePointerUp(event)
expect(mockReleasePointerCapture).not.toHaveBeenCalled()
})
})
})

View File

@@ -3085,6 +3085,7 @@
"selection": {
"selectedCount": "Assets Selected: {count}",
"multipleSelectedAssets": "Multiple assets selected",
"actions": "Selected asset actions",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"downloadSelectedAll": "Download all",

View File

@@ -123,6 +123,7 @@
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { getWebpMetadata } from './pnginfo'
function buildExifPayload(workflowJson: string): Uint8Array {
const fullStr = `workflow:${workflowJson}\0`
const strBytes = new TextEncoder().encode(fullStr)
const headerSize = 22
const buf = new Uint8Array(headerSize + strBytes.length)
const dv = new DataView(buf.buffer)
buf.set([0x49, 0x49], 0)
dv.setUint16(2, 0x002a, true)
dv.setUint32(4, 8, true)
dv.setUint16(8, 1, true)
dv.setUint16(10, 0, true)
dv.setUint16(12, 2, true)
dv.setUint32(14, strBytes.length, true)
dv.setUint32(18, 22, true)
buf.set(strBytes, 22)
return buf
}
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
const exifPayload = buildExifPayload(workflowJson)
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
const buffer = new Uint8Array(totalSize)
const dv = new DataView(buffer.buffer)
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
dv.setUint32(4, totalSize - 8, true)
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
dv.setUint32(16, precedingChunkLength, true)
const exifStart = 20 + precedingPadded
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
dv.setUint32(exifStart + 4, exifPayload.length, true)
buffer.set(exifPayload, exifStart + 8)
return new File([buffer], 'test.webp', { type: 'image/webp' })
}
describe('getWebpMetadata', () => {
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
const workflow = '{"nodes":[]}'
const file = buildWebp(3, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
it('finds workflow when preceding chunk has even length (no padding)', async () => {
const workflow = '{"nodes":[1]}'
const file = buildWebp(4, workflow)
const metadata = await getWebpMetadata(file)
expect(metadata.workflow).toBe(workflow)
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import type { SystemStats } from '@/schemas/apiSchema'
import type { ErrorReportData } from './errorReportUtil'
import { generateErrorReport } from './errorReportUtil'
const baseSystemStats: SystemStats = {
system: {
os: 'linux',
comfyui_version: '1.0.0',
python_version: '3.11',
pytorch_version: '2.0',
embedded_python: false,
argv: ['main.py'],
ram_total: 0,
ram_free: 0
},
devices: []
}
const baseWorkflow = { nodes: [], links: [] } as unknown as ISerialisedGraph
function buildError(serverLogs: unknown): ErrorReportData {
return {
exceptionType: 'RuntimeError',
exceptionMessage: 'boom',
systemStats: baseSystemStats,
serverLogs: serverLogs as string,
workflow: baseWorkflow
}
}
describe('generateErrorReport', () => {
it('embeds string serverLogs verbatim', () => {
const report = generateErrorReport(buildError('line one\nline two'))
expect(report).toContain('line one\nline two')
expect(report).not.toContain('[object Object]')
})
it('stringifies object serverLogs instead of rendering [object Object]', () => {
const report = generateErrorReport(
buildError({ entries: [{ msg: 'hello' }] })
)
expect(report).not.toContain('[object Object]')
expect(report).toContain('"entries"')
expect(report).toContain('"msg": "hello"')
})
})