Merge remote-tracking branch 'origin/main' into pysssss/appmode/execution-tests

# Conflicts:
#	browser_tests/fixtures/helpers/AppModeHelper.ts
#	browser_tests/fixtures/selectors.ts
This commit is contained in:
pythongosssss
2026-04-08 02:48:43 -07:00
84 changed files with 3798 additions and 1108 deletions

View File

@@ -31,4 +31,5 @@ jobs:
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View File

@@ -144,6 +144,8 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -1,6 +1,8 @@
# E2E Testing Guidelines
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
See `@browser_tests/FLAKE_PREVENTION_RULES.md` when triaging or editing
flaky browser tests.
## Directory Structure

View File

@@ -0,0 +1,99 @@
# Browser Test Flake Prevention Rules
Reference this file as `@browser_tests/FLAKE_PREVENTION_RULES.md` when
debugging or updating flaky Playwright tests.
These rules are distilled from the PR 10817 stabilization thread chain. They
exist to make flaky-test triage faster and more repeatable.
## Quick Checklist
Before merging a flaky-test fix, confirm all of these are true:
- the latest CI artifact was inspected directly
- the root cause is stated as a race or readiness mismatch
- the fix waits on the real readiness boundary
- the assertion primitive matches the job
- the fix stays local unless a shared helper truly owns the race
- local verification uses a targeted rerun
## 1. Start With CI Evidence
- Do not trust the top-level GitHub check result alone.
- Inspect the latest Playwright `report.json` directly, even on a green run.
- Treat tests marked `flaky` in `report.json` as real work.
- Use `error-context.md`, traces, and page snapshots before editing code.
- Pull the newest run after each push instead of assuming the flaky set is
unchanged.
## 2. Wait For The Real Readiness Boundary
- Visible is not always ready.
- If the behavior depends on internal state, wait on that state.
- After canvas interactions, call `await comfyPage.nextFrame()` unless the
helper already guarantees a settled frame.
- After workflow reloads or node-definition refreshes, wait for the reload to
finish before continuing.
Common readiness boundaries:
- `node.imgs` populated before opening image context menus
- settings cleanup finished before asserting persisted state
- locale-triggered workflow reload finished before selecting nodes
- real builder UI ready, not transient helper metadata
## 3. Choose The Smallest Correct Assertion
- Use built-in retrying locator assertions when locator state is the behavior.
- Use `expect.poll()` for a single async value.
- Use `expect(async () => { ... }).toPass()` only when multiple assertions must
settle together.
- Do not make immediate assertions after async UI mutations, settings writes,
clipboard writes, or graph updates.
- Never use `waitForTimeout()` to hide a race.
```ts
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2'))
.toEqual([])
```
## 4. Prefer Behavioral Assertions
- Use screenshots only when appearance is the behavior under test.
- If a screenshot only indirectly proves behavior, replace it with a direct
assertion.
- Prefer assertions on link counts, positions, visible menu items, persisted
settings, and node state.
## 5. Keep Helper Changes Narrow
- Shared helpers should drive setup to a stable boundary.
- Do not encode one-spec timing assumptions into generic helpers.
- If a race only matters to one spec, prefer a local wait in that spec.
- If a helper fails before the real test begins, remove or relax the brittle
precondition and let downstream UI interaction prove readiness.
## 6. Verify Narrowly
- Prefer targeted reruns through `pnpm test:browser:local`.
- On Windows, prefer `file:line` or whole-spec arguments over `--grep` when the
wrapper has quoting issues.
- Use `--repeat-each 5` for targeted flake verification unless the failure needs
a different reproduction pattern.
- Verify with the smallest command that exercises the flaky path.
## Current Local Noise
These are local distractions, not automatic CI root causes:
- missing local input fixture files required by the test path
- missing local models directory
- teardown `EPERM` while restoring the local browser-test user data directory
- local screenshot baseline differences on Windows
Rules for handling local noise:
- first confirm whether it blocks the exact flaky path under investigation
- do not commit temporary local assets used only for verification
- do not commit local screenshot baselines

View File

@@ -0,0 +1,47 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "Load3D",
"pos": [50, 50],
"size": [400, 650],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
},
{
"name": "MESH",
"type": "MESH",
"links": null
}
],
"properties": {
"Node name for S&R": "Load3D"
},
"widgets_values": ["", 1024, 1024, "#000000"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,85 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [500, 100],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,72 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "UNKNOWN NODE",
"pos": [48, 86],
"size": [358, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null,
"slot_index": 0
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [],
"slot_index": 0,
"shape": 6
}
],
"properties": {
"Node name for S&R": "UNKNOWN NODE"
},
"widgets_values": ["wd-v1-4-moat-tagger-v2", 0.35, 0.85, false, false, ""]
},
{
"id": 10,
"type": "LoadImage",
"pos": [450, 86],
"size": [315, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -29,6 +29,8 @@ 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'
@@ -177,6 +179,7 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
/** Worker index to test user ID */
@@ -227,6 +230,7 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
}
@@ -448,6 +452,7 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
@@ -63,17 +64,13 @@ export class ComfyNodeSearchBox {
await this.input.waitFor({ state: 'visible' })
await this.input.fill(nodeName)
await this.dropdown.waitFor({ state: 'visible' })
if (options?.exact) {
await this.dropdown
.locator(`li[aria-label="${nodeName}"]`)
.first()
.click()
} else {
await this.dropdown
.locator('li')
.nth(options?.suggestionIndex || 0)
.click()
}
const nodeOption = options?.exact
? this.dropdown.locator(`li[aria-label="${nodeName}"]`).first()
: this.dropdown.locator('li').nth(options?.suggestionIndex ?? 0)
await expect(nodeOption).toBeVisible()
await nodeOption.click()
}
async addFilter(filterValue: string, filterType: string) {

View File

@@ -20,6 +20,10 @@ export class ContextMenu {
await this.page.getByRole('menuitem', { name }).click()
}
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
}
async clickLitegraphMenuItem(name: string): Promise<void> {
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
}
@@ -48,6 +52,18 @@ export class ContextMenu {
return this
}
/**
* Select a Vue node by clicking its header, then right-click to open
* the context menu. Vue nodes require a selection click before the
* right-click so the correct per-node menu items appear.
*/
async openForVueNode(header: Locator): Promise<this> {
await header.click()
await header.click({ button: 'right' })
await this.primeVueMenu.waitFor({ state: 'visible' })
return this
}
async waitForHidden(): Promise<void> {
const waitIfExists = async (locator: Locator, menuName: string) => {
const count = await locator.count()

View File

@@ -0,0 +1,306 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2025-01-15T10:00:00Z',
updated_at: '2025-01-15T10:00:00Z',
last_access_time: '2025-01-15T10:00:00Z',
user_metadata: { base_model: 'sd15' },
...overrides
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-01T09:00:00Z',
updated_at: '2025-03-01T09:00:00Z',
last_access_time: '2025-03-01T09:00:00Z',
...overrides
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:00:00Z',
updated_at: '2025-03-10T12:00:00Z',
last_access_time: '2025-03-10T12:00:00Z',
...overrides
}
}
export const STABLE_CHECKPOINT: Asset = createModelAsset({
id: 'test-checkpoint-001',
name: 'sd_xl_base_1.0.safetensors',
size: 6_938_078_208,
tags: ['models', 'checkpoints'],
user_metadata: {
base_model: 'sdxl',
description: 'Stable Diffusion XL Base 1.0'
},
created_at: '2025-01-15T10:30:00Z',
updated_at: '2025-01-15T10:30:00Z'
})
export const STABLE_CHECKPOINT_2: Asset = createModelAsset({
id: 'test-checkpoint-002',
name: 'v1-5-pruned-emaonly.safetensors',
size: 4_265_146_304,
tags: ['models', 'checkpoints'],
user_metadata: {
base_model: 'sd15',
description: 'Stable Diffusion 1.5 Pruned EMA-Only'
},
created_at: '2025-01-20T08:00:00Z',
updated_at: '2025-01-20T08:00:00Z'
})
export const STABLE_LORA: Asset = createModelAsset({
id: 'test-lora-001',
name: 'detail_enhancer_v1.2.safetensors',
size: 184_549_376,
tags: ['models', 'loras'],
user_metadata: {
base_model: 'sdxl',
description: 'Detail Enhancement LoRA'
},
created_at: '2025-02-20T14:00:00Z',
updated_at: '2025-02-20T14:00:00Z'
})
export const STABLE_LORA_2: Asset = createModelAsset({
id: 'test-lora-002',
name: 'add_detail_v2.safetensors',
size: 226_492_416,
tags: ['models', 'loras'],
user_metadata: {
base_model: 'sd15',
description: 'Add Detail LoRA v2'
},
created_at: '2025-02-25T11:00:00Z',
updated_at: '2025-02-25T11:00:00Z'
})
export const STABLE_VAE: Asset = createModelAsset({
id: 'test-vae-001',
name: 'sdxl_vae.safetensors',
size: 334_641_152,
tags: ['models', 'vae'],
user_metadata: {
base_model: 'sdxl',
description: 'SDXL VAE'
},
created_at: '2025-01-18T16:00:00Z',
updated_at: '2025-01-18T16:00:00Z'
})
export const STABLE_EMBEDDING: Asset = createModelAsset({
id: 'test-embedding-001',
name: 'bad_prompt_v2.pt',
size: 32_768,
mime_type: 'application/x-pytorch',
tags: ['models', 'embeddings'],
user_metadata: {
base_model: 'sd15',
description: 'Negative Embedding: Bad Prompt v2'
},
created_at: '2025-02-01T09:30:00Z',
updated_at: '2025-02-01T09:30:00Z'
})
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
id: 'test-input-001',
name: 'reference_photo.png',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-01T09:00:00Z',
updated_at: '2025-03-01T09:00:00Z'
})
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
id: 'test-input-002',
name: 'mask_layer.png',
size: 1_048_576,
mime_type: 'image/png',
tags: ['input'],
created_at: '2025-03-05T10:00:00Z',
updated_at: '2025-03-05T10:00:00Z'
})
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
id: 'test-input-003',
name: 'clip_720p.mp4',
size: 15_728_640,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2025-03-08T14:30:00Z',
updated_at: '2025-03-08T14:30:00Z'
})
export const STABLE_OUTPUT: Asset = createOutputAsset({
id: 'test-output-001',
name: 'ComfyUI_00001_.png',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:00:00Z',
updated_at: '2025-03-10T12:00:00Z'
})
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
id: 'test-output-002',
name: 'ComfyUI_00002_.png',
size: 3_670_016,
mime_type: 'image/png',
tags: ['output'],
created_at: '2025-03-10T12:05:00Z',
updated_at: '2025-03-10T12:05:00Z'
})
export const ALL_MODEL_FIXTURES: Asset[] = [
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2,
STABLE_LORA,
STABLE_LORA_2,
STABLE_VAE,
STABLE_EMBEDDING
]
export const ALL_INPUT_FIXTURES: Asset[] = [
STABLE_INPUT_IMAGE,
STABLE_INPUT_IMAGE_2,
STABLE_INPUT_VIDEO
]
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
const CHECKPOINT_NAMES = [
'sd_xl_base_1.0.safetensors',
'v1-5-pruned-emaonly.safetensors',
'sd_xl_refiner_1.0.safetensors',
'dreamshaper_8.safetensors',
'realisticVision_v51.safetensors',
'deliberate_v3.safetensors',
'anything_v5.safetensors',
'counterfeit_v3.safetensors',
'revAnimated_v122.safetensors',
'majicmixRealistic_v7.safetensors'
]
const LORA_NAMES = [
'detail_enhancer_v1.2.safetensors',
'add_detail_v2.safetensors',
'epi_noiseoffset_v2.safetensors',
'lcm_lora_sdxl.safetensors',
'film_grain_v1.safetensors',
'sharpness_fix_v2.safetensors',
'better_hands_v1.safetensors',
'smooth_skin_v3.safetensors',
'color_pop_v1.safetensors',
'bokeh_effect_v2.safetensors'
]
const INPUT_NAMES = [
'reference_photo.png',
'mask_layer.png',
'clip_720p.mp4',
'depth_map.png',
'control_pose.png',
'sketch_input.jpg',
'inpainting_mask.png',
'style_reference.png',
'batch_001.png',
'batch_002.png'
]
const EXTENSION_MIME_MAP: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
flac: 'audio/flac'
}
function getMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream'
}
/**
* Generate N deterministic model assets of a given category.
* Uses sequential IDs and fixed names for screenshot stability.
*/
export function generateModels(
count: number,
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
): Asset[] {
const names = category === 'loras' ? LORA_NAMES : CHECKPOINT_NAMES
return Array.from({ length: Math.min(count, names.length) }, (_, i) =>
createModelAsset({
id: `gen-${category}-${String(i + 1).padStart(3, '0')}`,
name: names[i % names.length],
size: 2_000_000_000 + i * 500_000_000,
tags: ['models', category],
user_metadata: { base_model: i % 2 === 0 ? 'sdxl' : 'sd15' },
created_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`,
updated_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`
})
)
}
/**
* Generate N deterministic input file assets.
*/
export function generateInputFiles(count: number): Asset[] {
return Array.from({ length: Math.min(count, INPUT_NAMES.length) }, (_, i) => {
const name = INPUT_NAMES[i % INPUT_NAMES.length]
return createInputAsset({
id: `gen-input-${String(i + 1).padStart(3, '0')}`,
name,
size: 1_000_000 + i * 500_000,
mime_type: getMimeType(name),
tags: ['input'],
created_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`,
updated_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`
})
})
}
/**
* Generate N deterministic output assets.
*/
export function generateOutputAssets(count: number): Asset[] {
return Array.from({ length: count }, (_, i) =>
createOutputAsset({
id: `gen-output-${String(i + 1).padStart(3, '0')}`,
name: `ComfyUI_${String(i + 1).padStart(5, '0')}_.png`,
size: 3_000_000 + i * 200_000,
mime_type: 'image/png',
tags: ['output'],
created_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
updated_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`
})
)
}

View File

@@ -4,6 +4,7 @@ import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import { OutputHistoryComponent } from '../components/OutputHistory'
import { AppModeWidgetHelper } from './AppModeWidgetHelper'
import { BuilderFooterHelper } from './BuilderFooterHelper'
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
import { BuilderSelectHelper } from './BuilderSelectHelper'
@@ -15,6 +16,7 @@ export class AppModeHelper {
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly widgets: AppModeWidgetHelper
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
@@ -22,12 +24,24 @@ export class AppModeHelper {
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.widgets = new AppModeWidgetHelper(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
}
/** Enable the linear mode feature flag and top menu. */
async enableLinearMode() {
await this.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await this.comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
@@ -98,6 +112,13 @@ export class AppModeHelper {
.first()
}
/** The Run button in the app mode footer. */
get runButton(): Locator {
return this.page
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

View File

@@ -0,0 +1,93 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
*
* Widgets are located by their key (format: "nodeId:widgetName") via the
* `data-widget-key` attribute on each widget item.
*/
export class AppModeWidgetHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get container(): Locator {
return this.comfyPage.appMode.linearWidgets
}
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
getWidgetItem(key: string): Locator {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)
await widget.locator('textarea').fill(value)
}
/**
* Set a number input widget value (INT or FLOAT).
* Targets the last input inside the widget — this works for both
* ScrubableNumberInput (single input) and slider+InputNumber combos
* (last input is the editable number field).
*/
async fillNumber(key: string, value: string) {
const widget = this.getWidgetItem(key)
const input = widget.locator('input').last()
await input.fill(value)
await input.press('Enter')
}
/** Fill a string text input widget (e.g. filename_prefix). */
async fillText(key: string, value: string) {
const widget = this.getWidgetItem(key)
await widget.locator('input').fill(value)
}
/** Select an option from a combo/select widget. */
async selectOption(key: string, optionName: string) {
const widget = this.getWidgetItem(key)
await widget.getByRole('combobox').click()
await this.page
.getByRole('option', { name: optionName, exact: true })
.click()
}
/**
* Intercept the /api/prompt POST, click Run, and return the prompt payload.
* Fulfills the route with a mock success response.
*/
async runAndCapturePrompt(): Promise<
Record<string, { inputs: Record<string, unknown> }>
> {
let promptBody: Record<string, { inputs: Record<string, unknown> }> | null =
null
await this.page.route(
'**/api/prompt',
async (route, req) => {
promptBody = req.postDataJSON().prompt
await route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: 'test-id',
number: 1,
node_errors: {}
})
})
},
{ times: 1 }
)
const responsePromise = this.page.waitForResponse('**/api/prompt')
await this.comfyPage.appMode.runButton.click()
await responsePromise
if (!promptBody) throw new Error('No prompt payload captured')
return promptBody
}
}

View File

@@ -0,0 +1,317 @@
import type { Page, Route } from '@playwright/test'
import type {
Asset,
ListAssetsResponse,
UpdateAssetData
} from '@comfyorg/ingest-types'
import {
generateModels,
generateInputFiles,
generateOutputAssets
} from '../data/assetFixtures'
export interface MutationRecord {
endpoint: string
method: string
url: string
body: unknown
timestamp: number
}
interface PaginationOptions {
total: number
hasMore: boolean
}
export interface AssetConfig {
readonly assets: ReadonlyMap<string, Asset>
readonly pagination: PaginationOptions | null
readonly uploadResponse: Record<string, unknown> | null
}
function emptyConfig(): AssetConfig {
return { assets: new Map(), pagination: null, uploadResponse: null }
}
export type AssetOperator = (config: AssetConfig) => AssetConfig
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
const merged = new Map(config.assets)
for (const asset of newAssets) {
merged.set(asset.id, asset)
}
return { ...config, assets: merged }
}
export function withModels(
countOrAssets: number | Asset[],
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
): AssetOperator {
return (config) => {
const assets =
typeof countOrAssets === 'number'
? generateModels(countOrAssets, category)
: countOrAssets
return addAssets(config, assets)
}
}
export function withInputFiles(countOrAssets: number | Asset[]): AssetOperator {
return (config) => {
const assets =
typeof countOrAssets === 'number'
? generateInputFiles(countOrAssets)
: countOrAssets
return addAssets(config, assets)
}
}
export function withOutputAssets(
countOrAssets: number | Asset[]
): AssetOperator {
return (config) => {
const assets =
typeof countOrAssets === 'number'
? generateOutputAssets(countOrAssets)
: countOrAssets
return addAssets(config, assets)
}
}
export function withAsset(asset: Asset): AssetOperator {
return (config) => addAssets(config, [asset])
}
export function withPagination(options: PaginationOptions): AssetOperator {
return (config) => ({ ...config, pagination: options })
}
export function withUploadResponse(
response: Record<string, unknown>
): AssetOperator {
return (config) => ({ ...config, uploadResponse: response })
}
export class AssetHelper {
private store: Map<string, Asset>
private paginationOptions: PaginationOptions | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
private mutations: MutationRecord[] = []
private uploadResponse: Record<string, unknown> | null
constructor(
private readonly page: Page,
config: AssetConfig = emptyConfig()
) {
this.store = new Map(config.assets)
this.paginationOptions = config.pagination
this.uploadResponse = config.uploadResponse
}
async mock(): Promise<void> {
const handler = async (route: Route) => {
const url = new URL(route.request().url())
const method = route.request().method()
const path = url.pathname
const isMutation = ['POST', 'PUT', 'DELETE'].includes(method)
let body: Record<string, unknown> | null = null
if (isMutation) {
try {
body = route.request().postDataJSON()
} catch {
body = null
}
}
if (isMutation) {
this.mutations.push({
endpoint: path,
method,
url: route.request().url(),
body,
timestamp: Date.now()
})
}
if (method === 'GET' && /\/assets\/?$/.test(path))
return this.handleListAssets(route, url)
if (method === 'GET' && /\/assets\/[^/]+$/.test(path))
return this.handleGetAsset(route, path)
if (method === 'PUT' && /\/assets\/[^/]+$/.test(path))
return this.handleUpdateAsset(route, path, body)
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
return this.handleDeleteAsset(route, path)
if (method === 'POST' && /\/assets\/?$/.test(path))
return this.handleUploadAsset(route)
if (method === 'POST' && path.endsWith('/assets/download'))
return this.handleDownloadAsset(route)
return route.fallback()
}
const pattern = '**/assets**'
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
async mockError(
statusCode: number,
error: string = 'Internal Server Error'
): Promise<void> {
const handler = async (route: Route) => {
return route.fulfill({
status: statusCode,
json: { error }
})
}
const pattern = '**/assets**'
this.routeHandlers.push({ pattern, handler })
await this.page.route(pattern, handler)
}
async fetch(
path: string,
init?: RequestInit
): Promise<{ status: number; body: unknown }> {
return this.page.evaluate(
async ([fetchUrl, fetchInit]) => {
const res = await fetch(fetchUrl, fetchInit)
const text = await res.text()
let body: unknown
try {
body = JSON.parse(text)
} catch {
body = text
}
return { status: res.status, body }
},
[path, init] as const
)
}
configure(...operators: AssetOperator[]): void {
const config = operators.reduce<AssetConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
this.store = new Map(config.assets)
this.paginationOptions = config.pagination
this.uploadResponse = config.uploadResponse
}
getMutations(): MutationRecord[] {
return [...this.mutations]
}
getAssets(): Asset[] {
return [...this.store.values()]
}
getAsset(id: string): Asset | undefined {
return this.store.get(id)
}
get assetCount(): number {
return this.store.size
}
private handleListAssets(route: Route, url: URL) {
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
let filtered = this.getFilteredAssets(includeTags)
if (limit > 0) {
filtered = filtered.slice(offset, offset + limit)
}
const response: ListAssetsResponse = {
assets: filtered,
total: this.paginationOptions?.total ?? this.store.size,
has_more: this.paginationOptions?.hasMore ?? false
}
return route.fulfill({ json: response })
}
private handleGetAsset(route: Route, path: string) {
const id = path.split('/').pop()!
const asset = this.store.get(id)
if (asset) return route.fulfill({ json: asset })
return route.fulfill({ status: 404, json: { error: 'Not found' } })
}
private handleUpdateAsset(
route: Route,
path: string,
body: UpdateAssetData['body'] | null
) {
const id = path.split('/').pop()!
const asset = this.store.get(id)
if (asset) {
const updated = {
...asset,
...(body ?? {}),
updated_at: new Date().toISOString()
}
this.store.set(id, updated)
return route.fulfill({ json: updated })
}
return route.fulfill({ status: 404, json: { error: 'Not found' } })
}
private handleDeleteAsset(route: Route, path: string) {
const id = path.split('/').pop()!
this.store.delete(id)
return route.fulfill({ status: 204, body: '' })
}
private handleUploadAsset(route: Route) {
const response = this.uploadResponse ?? {
id: `upload-${Date.now()}`,
name: 'uploaded_file.safetensors',
tags: ['models', 'checkpoints'],
created_at: new Date().toISOString(),
created_new: true
}
return route.fulfill({ status: 201, json: response })
}
private handleDownloadAsset(route: Route) {
return route.fulfill({
status: 202,
json: {
task_id: 'download-task-001',
status: 'created',
message: 'Download started'
}
})
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.store.clear()
this.mutations = []
this.paginationOptions = null
this.uploadResponse = null
}
private getFilteredAssets(tags: string[]): Asset[] {
const assets = [...this.store.values()]
if (tags.length === 0) return assets
return assets.filter((asset) =>
tags.every((tag) => (asset.tags ?? []).includes(tag))
)
}
}
export function createAssetHelper(
page: Page,
...operators: AssetOperator[]
): AssetHelper {
const config = operators.reduce<AssetConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new AssetHelper(page, config)
}

View File

@@ -41,10 +41,21 @@ export const TestIds = {
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
errorDialogShowReport: 'error-dialog-show-report',
errorDialogContactSupport: 'error-dialog-contact-support',
errorDialogCopyReport: 'error-dialog-copy-report',
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
@@ -129,6 +140,9 @@ export const TestIds = {
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run'
},
appMode: {
widgetItem: 'app-mode-widget-item'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -166,6 +180,7 @@ export type TestIdValue =
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],

View File

@@ -6,7 +6,6 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { comfyExpect } from '../fixtures/ComfyPage'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
interface BuilderSetupResult {
inputNodeTitle: string
@@ -63,15 +62,9 @@ export async function setupSubgraphBuilder(
): Promise<void> {
await setupBuilder(comfyPage, async (ksampler) => {
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
expect(promotedNames).toContain('seed')
return {
inputNodeTitle: 'New Subgraph',
widgetNames: ['seed']
@@ -112,10 +105,9 @@ export async function openWorkflowFromSidebar(
await workflowsTab.getPersistedItem(name).dblclick()
await comfyPage.nextFrame()
await comfyExpect(async () => {
const path = await comfyPage.workflow.getActiveWorkflowPath()
expect(path).toContain(name)
}).toPass({ timeout: 5000 })
await expect
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
.toContain(name)
}
/** Save the workflow, reopen it, and enter app mode. */

View File

@@ -0,0 +1,19 @@
import type { Page } from '@playwright/test'
export async function interceptClipboardWrite(page: Page) {
await page.evaluate(() => {
const w = window as Window & { __copiedText?: string }
w.__copiedText = ''
navigator.clipboard.writeText = async (text: string) => {
w.__copiedText = text
}
})
}
export async function getClipboardText(page: Page): Promise<string> {
return (
(await page.evaluate(
() => (window as Window & { __copiedText?: string }).__copiedText
)) ?? ''
)
}

View File

@@ -18,12 +18,9 @@ export class ComfyTemplates {
}
async expectMinimumCardCount(count: number) {
await expect(async () => {
const cardCount = await this.allTemplateCards.count()
expect(cardCount).toBeGreaterThanOrEqual(count)
}).toPass({
timeout: 1_000
})
await expect
.poll(() => this.allTemplateCards.count())
.toBeGreaterThanOrEqual(count)
}
async loadTemplate(id: string) {

View File

@@ -60,13 +60,7 @@ async function addNode(page: Page, nodeType: string): Promise<string> {
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
})
test('Select dropdown is not clipped in app mode panel', async ({

View File

@@ -9,13 +9,7 @@ import {
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true

View File

@@ -0,0 +1,94 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/** One representative of each widget type from the default workflow. */
type WidgetType = 'textarea' | 'number' | 'select' | 'text'
const WIDGET_TEST_DATA: {
nodeId: string
widgetName: string
type: WidgetType
fill: string
expected: unknown
}[] = [
{
nodeId: '6',
widgetName: 'text',
type: 'textarea',
fill: 'test prompt',
expected: 'test prompt'
},
{
nodeId: '5',
widgetName: 'width',
type: 'number',
fill: '768',
expected: 768
},
{
nodeId: '3',
widgetName: 'cfg',
type: 'number',
fill: '3.5',
expected: 3.5
},
{
nodeId: '3',
widgetName: 'sampler_name',
type: 'select',
fill: 'uni_pc',
expected: 'uni_pc'
},
{
nodeId: '9',
widgetName: 'filename_prefix',
type: 'text',
fill: 'test_prefix',
expected: 'test_prefix'
}
]
test.describe('App mode widget values in prompt', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.appMode.enableLinearMode()
})
test('Widget values are sent correctly in prompt POST', async ({
comfyPage
}) => {
const { appMode } = comfyPage
const inputs: [string, string][] = WIDGET_TEST_DATA.map(
({ nodeId, widgetName }) => [nodeId, widgetName]
)
await appMode.enterAppModeWithInputs(inputs)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
for (const { nodeId, widgetName, type, fill } of WIDGET_TEST_DATA) {
const key = `${nodeId}:${widgetName}`
switch (type) {
case 'textarea':
await appMode.widgets.fillTextarea(key, fill)
break
case 'number':
await appMode.widgets.fillNumber(key, fill)
break
case 'select':
await appMode.widgets.selectOption(key, fill)
break
case 'text':
await appMode.widgets.fillText(key, fill)
break
default:
throw new Error(`Unknown widget type: ${type satisfies never}`)
}
}
const prompt = await appMode.widgets.runAndCapturePrompt()
for (const { nodeId, widgetName, expected } of WIDGET_TEST_DATA) {
expect(prompt[nodeId].inputs[widgetName]).toBe(expected)
}
})
})

View File

@@ -0,0 +1,382 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
withInputFiles,
withOutputAssets,
withAsset,
withPagination,
withUploadResponse
} from '../fixtures/helpers/AssetHelper'
import {
STABLE_CHECKPOINT,
STABLE_LORA,
STABLE_INPUT_IMAGE,
STABLE_OUTPUT
} from '../fixtures/data/assetFixtures'
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
comfyPage
}) => {
const helper = createAssetHelper(
comfyPage.page,
withModels(3, 'checkpoints')
)
expect(helper.assetCount).toBe(3)
expect(
helper.getAssets().every((a) => a.tags?.includes('checkpoints'))
).toBe(true)
})
test('composes multiple operators', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withModels(2, 'checkpoints'),
withInputFiles(2),
withOutputAssets(1)
)
expect(helper.assetCount).toBe(5)
})
test('adds individual assets via withAsset', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA)
)
expect(helper.assetCount).toBe(2)
expect(helper.getAsset(STABLE_CHECKPOINT.id)).toMatchObject({
id: STABLE_CHECKPOINT.id,
name: STABLE_CHECKPOINT.name
})
})
test('withPagination sets pagination options', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withModels(2),
withPagination({ total: 100, hasMore: true })
)
expect(helper.assetCount).toBe(2)
})
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
)
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets`
)
expect(status).toBe(200)
const data = body as {
assets: unknown[]
total: number
has_more: boolean
}
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
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?limit=2&offset=0`
)
const data = body as {
assets: unknown[]
total: number
has_more: boolean
}
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
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
withAsset(STABLE_INPUT_IMAGE)
)
await assetApi.mock()
const { body } = await assetApi.fetch(
`${comfyPage.url}/api/assets?include_tags=models,checkpoints`
)
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
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
const found = await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`
)
expect(found.status).toBe(200)
const asset = found.body as { id: string }
expect(asset.id).toBe(STABLE_CHECKPOINT.id)
const notFound = await assetApi.fetch(
`${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
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'renamed.safetensors' })
}
)
expect(status).toBe(200)
const updated = body as { name: string }
expect(updated.name).toBe('renamed.safetensors')
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
const { status } = await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{ method: 'DELETE' }
)
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 }) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
tags: ['models'],
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets`,
{ method: 'POST' }
)
expect(status).toBe(201)
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
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets/download`,
{ method: 'POST' }
)
expect(status).toBe(202)
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
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
await assetApi.fetch(`${comfyPage.url}/api/assets`, { method: 'POST' })
await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'updated.safetensors' })
}
)
await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
{ method: 'DELETE' }
)
const mutations = assetApi.getMutations()
expect(mutations).toHaveLength(3)
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
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
await assetApi.fetch(`${comfyPage.url}/api/assets`)
await assetApi.fetch(
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
`${comfyPage.url}/api/assets`
)
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
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
await assetApi.fetch(`${comfyPage.url}/api/assets`, { method: 'POST' })
expect(assetApi.getMutations()).toHaveLength(1)
expect(assetApi.assetCount).toBe(1)
await assetApi.clearMocks()
expect(assetApi.getMutations()).toHaveLength(0)
expect(assetApi.assetCount).toBe(0)
})
})
test.describe('fixture generators', () => {
test('generateModels produces deterministic assets', async ({
comfyPage
}) => {
const helper = createAssetHelper(comfyPage.page, withModels(3, 'loras'))
const assets = helper.getAssets()
expect(assets).toHaveLength(3)
expect(assets.every((a) => a.tags?.includes('loras'))).toBe(true)
expect(assets.every((a) => a.tags?.includes('models'))).toBe(true)
const ids = assets.map((a) => a.id)
expect(new Set(ids).size).toBe(3)
})
test('generateInputFiles produces deterministic input assets', async ({
comfyPage
}) => {
const helper = createAssetHelper(comfyPage.page, withInputFiles(3))
const assets = helper.getAssets()
expect(assets).toHaveLength(3)
expect(assets.every((a) => a.tags?.includes('input'))).toBe(true)
})
test('generateOutputAssets produces deterministic output assets', async ({
comfyPage
}) => {
const helper = createAssetHelper(comfyPage.page, withOutputAssets(5))
const assets = helper.getAssets()
expect(assets).toHaveLength(5)
expect(assets.every((a) => a.tags?.includes('output'))).toBe(true)
expect(assets.every((a) => a.name.startsWith('ComfyUI_'))).toBe(true)
})
test('stable fixtures have expected properties', async ({ comfyPage }) => {
const helper = createAssetHelper(
comfyPage.page,
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
withAsset(STABLE_INPUT_IMAGE),
withAsset(STABLE_OUTPUT)
)
const checkpoint = helper.getAsset(STABLE_CHECKPOINT.id)!
expect(checkpoint.tags).toContain('checkpoints')
expect(checkpoint.size).toBeGreaterThan(0)
expect(checkpoint.created_at).toBeTruthy()
const lora = helper.getAsset(STABLE_LORA.id)!
expect(lora.tags).toContain('loras')
const input = helper.getAsset(STABLE_INPUT_IMAGE.id)!
expect(input.tags).toContain('input')
const output = helper.getAsset(STABLE_OUTPUT.id)!
expect(output.tags).toContain('output')
})
})
})

View File

@@ -30,13 +30,7 @@ async function saveCloseAndReopenAsApp(
test.describe('Builder input reordering', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true

View File

@@ -26,13 +26,7 @@ async function reSaveAs(
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enableLinearMode()
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true

View File

@@ -27,41 +27,51 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
test('Can undo multiple operations', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.toast.getToastErrorCount()).toBe(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(2)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(2)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(1)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(1)
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getRedoQueueSize()).toBe(2)
})
})
@@ -154,7 +164,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.graph!.extra.foo = 'bar'
})
@@ -164,7 +174,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})

View File

@@ -38,8 +38,9 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
await comfyPage.clipboard.copy(null)
await comfyPage.clipboard.paste(null)
await comfyPage.clipboard.paste(null)
const resultString = await textBox.inputValue()
expect(resultString).toBe(originalString + originalString)
await expect
.poll(() => textBox.inputValue())
.toBe(originalString + originalString)
})
test('Can copy and paste widget value', async ({ comfyPage }) => {
@@ -114,20 +115,24 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
test('Can undo paste multiple nodes as single action', async ({
comfyPage
}) => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(1)
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
await comfyPage.canvas.click()
await comfyPage.keyboard.selectAll()
await comfyPage.page.mouse.move(10, 10)
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
const pasteCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(pasteCount).toBe(initialCount * 2)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount * 2)
await comfyPage.keyboard.undo()
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount)
})
test(
@@ -135,7 +140,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
{ tag: ['@node'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
const ksamplerNodes =
@@ -174,7 +179,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
{ timeout: 5_000 }
)
.toContain('image32x32')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
// Step 3: Click empty canvas area, paste image → creates new LoadImage
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })

View File

@@ -3,261 +3,11 @@ import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show error overlay when loading a workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing.*installed/i)
})
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing.*installed/i)
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
// Expand the pack group row to reveal node type names
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should show MissingNodeCard in errors tab when clicking See Errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
})
})
test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
// Dismiss the error overlay
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(errorOverlay).not.toBeVisible()
// Make a change to the graph by moving a node
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
// Undo and redo should not resurface the error overlay
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
test.describe('Execution error', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for the error overlay to be visible
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
})
})
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for error overlay and click "See Errors"
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
await expect(copyButton).toBeVisible()
})
})
test.describe('Missing models in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
})
test('Should show error overlay with missing models when workflow has missing models', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/required model.*missing/i)
})
test('Should show missing models from node properties', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_from_node_properties'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/required model.*missing/i)
})
test('Should not show missing models when widget values have changed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
).not.toBeVisible()
})
})
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
@@ -379,38 +129,6 @@ test.describe('Support', () => {
})
})
test.describe('Error dialog', () => {
test('Should display an error dialog when graph configure fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const graph = window.graph!
;(graph as { configure: () => void }).configure = () => {
throw new Error('Error on configure!')
}
})
await comfyPage.workflow.loadWorkflow('default')
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
})
test('Should display an error dialog when prompt execution fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window.app!
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
await app.queuePrompt(0)
})
const errorDialog = comfyPage.page.locator('.comfy-error-report')
await expect(errorDialog).toBeVisible()
})
})
test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage

View File

@@ -83,7 +83,7 @@ test.describe('Sign In dialog', { tag: '@ui' }, () => {
})
test('Should close dialog via close button', async () => {
await dialog.close()
await dialog.closeButton.click()
await expect(dialog.root).toBeHidden()
})

View File

@@ -0,0 +1,139 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '../helpers/clipboardSpy'
async function triggerConfigureError(
comfyPage: ComfyPage,
message = 'Error on configure!'
) {
await comfyPage.page.evaluate((msg: string) => {
const graph = window.graph!
;(graph as { configure: () => void }).configure = () => {
throw new Error(msg)
}
}, message)
await comfyPage.workflow.loadWorkflow('default')
return comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
}
async function waitForPopupNavigation(page: Page, action: () => Promise<void>) {
const popupPromise = page.waitForEvent('popup')
await action()
const popup = await popupPromise
await popup.waitForLoadState()
return popup
}
test.describe('Error dialog', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Should display an error dialog when graph configure fails', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
})
test('Should display an error dialog when prompt execution fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(async () => {
const app = window.app!
app.api.queuePrompt = () => {
throw new Error('Error on queuePrompt!')
}
await app.queuePrompt(0)
})
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
await expect(errorDialog).toBeVisible()
})
test('Should display error message body', async ({ comfyPage }) => {
const errorDialog = await triggerConfigureError(
comfyPage,
'Test error message body'
)
await expect(errorDialog).toBeVisible()
await expect(errorDialog).toContainText('Test error message body')
})
test('Should show report section when "Show Report" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
await expect(errorDialog.locator('pre')).not.toBeVisible()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
const reportPre = errorDialog.locator('pre')
await expect(reportPre).toBeVisible()
await expect(reportPre).toHaveText(/\S/)
await expect(
errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport)
).not.toBeVisible()
})
test('Should copy report to clipboard when "Copy to Clipboard" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
await errorDialog.getByTestId(TestIds.dialogs.errorDialogShowReport).click()
await expect(errorDialog.locator('pre')).toBeVisible()
await interceptClipboardWrite(comfyPage.page)
await errorDialog.getByTestId(TestIds.dialogs.errorDialogCopyReport).click()
const reportText = await errorDialog.locator('pre').textContent()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toBe(reportText)
})
test('Should open GitHub issues search when "Find Issues" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
const popup = await waitForPopupNavigation(comfyPage.page, () =>
errorDialog.getByTestId(TestIds.dialogs.errorDialogFindIssues).click()
)
const url = new URL(popup.url())
expect(url.hostname).toBe('github.com')
expect(url.pathname).toContain('/issues')
await popup.close()
})
test('Should open contact support when "Help Fix This" is clicked', async ({
comfyPage
}) => {
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
const popup = await waitForPopupNavigation(comfyPage.page, () =>
errorDialog.getByTestId(TestIds.dialogs.errorDialogContactSupport).click()
)
const url = new URL(popup.url())
expect(url.hostname).toBe('support.comfy.org')
await popup.close()
})
})

View File

@@ -0,0 +1,195 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
function getOverlay(page: Page) {
return page.getByTestId(TestIds.dialogs.errorOverlay)
}
function getSeeErrorsButton(page: Page) {
return getOverlay(page).getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
}
test.describe('Labels', () => {
test('Should display singular error count label for single error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getOverlay(comfyPage.page)).toContainText(/1 ERROR/i)
})
test('Should display "Show missing nodes" button for missing node errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing nodes/i
)
})
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await comfyPage.workflow.loadWorkflow('missing/missing_models')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing models/i
)
})
test('Should display "Show missing inputs" button for missing media errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/Show missing inputs/i
)
})
test('Should display generic "See Errors" button for multiple error types', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_and_media')
await expect(getOverlay(comfyPage.page)).toBeVisible()
await expect(getSeeErrorsButton(comfyPage.page)).toContainText(
/See Errors/i
)
})
})
test.describe('Persistence', () => {
test('Does not resurface missing nodes on undo/redo', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = getOverlay(comfyPage.page)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).not.toBeVisible()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.page.mouse.move(400, 300)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(450, 350, { steps: 5 })
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
})
})
test.describe('See Errors flow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(getOverlay(comfyPage.page)).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = getOverlay(comfyPage.page)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})
})

View File

@@ -1,93 +0,0 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
async function triggerExecutionError(comfyPage: {
canvasOps: { disconnectEdge: () => Promise<void> }
page: Page
command: { executeCommand: (cmd: string) => Promise<void> }
}) {
await comfyPage.canvasOps.disconnectEdge()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
}
test('Error overlay appears on execution error', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})
test('Error overlay shows error message', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await expect(overlay).toHaveText(/\S/)
})
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
})
test('"Dismiss" closes overlay without opening panel', async ({
comfyPage
}) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
await triggerExecutionError(comfyPage)
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(overlay).toBeVisible()
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
})
})

View File

@@ -0,0 +1,40 @@
import type { Locator } from '@playwright/test'
export class Load3DHelper {
constructor(readonly node: Locator) {}
get canvas(): Locator {
return this.node.locator('canvas')
}
get menuButton(): Locator {
return this.node.getByRole('button', { name: /show menu/i })
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: /start recording/i })
}
get colorInput(): Locator {
return this.node.locator('input[type="color"]')
}
getUploadButton(label: string): Locator {
return this.node.getByText(label)
}
getMenuCategory(name: string): Locator {
return this.node.getByText(name, { exact: true })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async setBackgroundColor(hex: string): Promise<void> {
await this.colorInput.evaluate((el, value) => {
;(el as HTMLInputElement).value = value
el.dispatchEvent(new Event('input', { bubbles: true }))
}, hex)
}
}

View File

@@ -0,0 +1,97 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { Load3DHelper } from './Load3DHelper'
test.describe('Load3D', () => {
let load3d: Load3DHelper
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('3d/load3d_node')
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeLocator('1')
load3d = new Load3DHelper(node)
})
test(
'Renders canvas with upload buttons and controls menu',
{ tag: ['@smoke', '@screenshot'] },
async () => {
await expect(load3d.node).toBeVisible()
await expect(load3d.canvas).toBeVisible()
const canvasBox = await load3d.canvas.boundingBox()
expect(canvasBox!.width).toBeGreaterThan(0)
expect(canvasBox!.height).toBeGreaterThan(0)
await expect(load3d.getUploadButton('upload 3d model')).toBeVisible()
await expect(
load3d.getUploadButton('upload extra resources')
).toBeVisible()
await expect(load3d.getUploadButton('clear')).toBeVisible()
await expect(load3d.menuButton).toBeVisible()
await expect(load3d.node).toHaveScreenshot('load3d-empty-node.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Controls menu opens and shows all categories',
{ tag: ['@smoke', '@screenshot'] },
async () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
{ maxDiffPixelRatio: 0.05 }
)
}
)
test(
'Changing background color updates the scene',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
await load3d.setBackgroundColor('#cc3333')
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const config = n?.properties?.['Scene Config'] as
| Record<string, string>
| undefined
return config?.backgroundColor
}),
{ timeout: 3000 }
)
.toBe('#cc3333')
await expect(load3d.node).toHaveScreenshot('load3d-red-background.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Recording controls are visible for Load3D',
{ tag: '@smoke' },
async () => {
await expect(load3d.recordingButton).toBeVisible()
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -48,10 +48,9 @@ test.describe('Menu', { tag: '@ui' }, () => {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
await comfyPage.nextFrame()
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
await expect
.poll(() => comfyPage.menu.topbar.getTabNames())
.toEqual(['Unsaved Workflow'])
})
})

View File

@@ -4,6 +4,7 @@ import {
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import type { WorkspaceStore } from '../types/globals'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
@@ -23,6 +24,67 @@ async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
await nodeRef.click('title')
}
async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
await expect(comfyPage.selectionToolbox).toBeVisible()
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
await expect(helpButton).toBeVisible()
await helpButton.click({ force: true })
await comfyPage.nextFrame()
return comfyPage.page.getByTestId('properties-panel')
}
async function setLocaleAndWaitForWorkflowReload(
comfyPage: ComfyPage,
locale: string
) {
await comfyPage.page.evaluate(async (targetLocale) => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) {
throw new Error('No active workflow while waiting for locale reload')
}
const changeTracker = workflow.changeTracker.constructor as unknown as {
isLoadingGraph: boolean
}
let sawLoading = false
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}
if (sawLoading && !changeTracker.isLoadingGraph) {
resolve()
return
}
if (performance.now() > timeoutAt) {
reject(
new Error(
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
)
)
return
}
requestAnimationFrame(tick)
}
tick()
})
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
await waitForReload
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
@@ -46,20 +108,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
// Select the node with panning to ensure toolbox is visible
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Wait for selection toolbox to appear
await expect(comfyPage.selectionToolbox).toBeVisible()
// Click the help button in the selection toolbox
const helpButton = comfyPage.selectionToolbox.locator(
'button[data-testid="info-button"]'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the help page is shown for the correct node
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
@@ -168,16 +218,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
// Verify loading spinner is shown
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
// Wait for content to load
@@ -201,16 +243,8 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
// Verify fallback content is shown (description, inputs, outputs)
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('Description')
await expect(helpPage).toContainText('Inputs')
await expect(helpPage).toContainText('Outputs')
@@ -239,14 +273,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSampler Documentation')
// Check that relative image paths are prefixed correctly
@@ -290,14 +317,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
// Check relative video paths are prefixed
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
@@ -364,15 +384,9 @@ This is documentation for a custom node.
await selectNodeWithPan(comfyPage, firstNode)
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
if (await helpButton.isVisible()) {
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
@@ -408,14 +422,7 @@ This is documentation for a custom node.
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
// Dangerous elements should be removed
await expect(helpPage.locator('script')).toHaveCount(0)
@@ -471,27 +478,20 @@ This is English documentation.
})
// Set locale to Japanese
await comfyPage.settings.setSetting('Comfy.Locale', 'ja')
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
try {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.waitFor({ state: 'visible', timeout: 10_000 })
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
// Reset locale
await comfyPage.settings.setSetting('Comfy.Locale', 'en')
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
} finally {
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
}
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {
@@ -505,14 +505,7 @@ This is English documentation.
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
@@ -552,14 +545,7 @@ This is English documentation.
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button[data-testid="info-button"]'
)
await helpButton.click()
const helpPage = comfyPage.page.locator(
'[data-testid="properties-panel"]'
)
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSampler Help')
await expect(helpPage).toContainText('This is KSampler documentation')

View File

@@ -4,6 +4,16 @@ import {
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
async function waitForSearchInsertion(
comfyPage: ComfyPage,
initialNodeCount: number
) {
await expect(comfyPage.searchBox.input).toHaveCount(0)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialNodeCount + 1)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
@@ -60,18 +70,22 @@ test.describe('Node search box', { tag: '@node' }, () => {
})
test('Can add node', { tag: '@screenshot' }, async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.doubleClick()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
})
test('Can auto link node', { tag: '@screenshot' }, async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
suggestionIndex: 0
})
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
@@ -81,6 +95,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)
const checkpointNode = await comfyPage.nodeOps.getNodeRefById(4)
@@ -98,6 +113,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
suggestionIndex: 0
})
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'auto-linked-node-batch.png'
)
@@ -108,12 +124,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Link release connecting to node with no slots',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler', {
exact: true
})
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'added-node-no-connection.png'
)
@@ -296,11 +314,13 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'Can search and add node from context menu',
{ tag: '@screenshot' },
async ({ comfyPage, comfyMouse }) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.canvasOps.disconnectEdge()
await comfyMouse.move({ x: 10, y: 10 })
await comfyPage.contextMenu.clickMenuItem('Search')
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
await waitForSearchInsertion(comfyPage, initialNodeCount)
await expect(comfyPage.canvas).toHaveScreenshot(
'link-context-menu-search.png'
)

View File

@@ -0,0 +1,17 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
export async function openErrorsTabViaSeeErrors(
comfyPage: ComfyPage,
workflow: string
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}

View File

@@ -1,31 +1,73 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { PropertiesPanelHelper } from './PropertiesPanelHelper'
test.describe('Properties panel - Errors tab', () => {
let panel: PropertiesPanelHelper
test.describe('Errors tab - common', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
})
test('should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
await expect(panel.errorsTabIcon).toBeVisible()
})
test('should not show Errors tab when errors are disabled', async ({
comfyPage
}) => {
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.errorsTabIcon).not.toBeVisible()
test.describe('Tab visibility', () => {
test('Should show Errors tab when errors exist', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(panel.errorsTabIcon).toBeVisible()
})
test('Should not show Errors tab when setting is disabled', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
false
)
await comfyPage.actionbar.propertiesButton.click()
await comfyPage.nextFrame()
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(panel.errorsTabIcon).not.toBeVisible()
})
})
test.describe('Search and filter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
})
test('Should filter execution errors by search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
const runtimePanel = comfyPage.page.getByTestId(
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await expect(runtimePanel).not.toBeVisible()
})
})
})

View File

@@ -0,0 +1,57 @@
import type { ComfyPage } from '../../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
await comfyPage.setup()
})
async function openExecutionErrorTab(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await expect(errorOverlay).not.toBeVisible()
}
test('Should show Find on GitHub and Copy buttons in error card', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorCardFindOnGithub)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
).toBeVisible()
})
test('Should show error message in runtime error panel', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
const runtimePanel = comfyPage.page.getByTestId(
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText(/\S/)
})
})

View File

@@ -1,21 +1,9 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function loadMissingMediaAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow = 'missing/missing_media_single'
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -48,7 +36,7 @@ function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Missing media inputs in Error Tab', () => {
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
@@ -58,27 +46,8 @@ test.describe('Missing media inputs in Error Tab', () => {
})
test.describe('Detection', () => {
test('Shows error overlay when workflow has missing media inputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing required inputs/i)
})
test('Shows missing media group in errors tab after clicking See Errors', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
@@ -88,7 +57,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_media_multiple'
)
@@ -99,30 +68,20 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
test('Does not show error overlay when all media inputs exist', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
})
})
test.describe('Upload flow (2-step confirm)', () => {
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -132,11 +91,11 @@ test.describe('Missing media inputs in Error Tab', () => {
})
})
test.describe('Library select flow (2-step confirm)', () => {
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
@@ -162,7 +121,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -181,7 +140,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
@@ -195,7 +154,7 @@ test.describe('Missing media inputs in Error Tab', () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import {
interceptClipboardWrite,
getClipboardText
} from '../../helpers/clipboardSpy'
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeVisible()
})
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
})
test('Should expand model row to show referencing nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_models_with_nodes'
)
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).not.toBeVisible()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expandButton.first().click()
await expect(locateButton.first()).toBeVisible()
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().click()
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
test('Should show Copy URL button for non-asset models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
})
test('Should show Download button for downloadable models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
const downloadButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
})
})
})

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { openErrorsTabViaSeeErrors } from './ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
})
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await expect(missingNodeCard).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).toBeVisible()
await missingNodeCard
.getByRole('button', { name: /collapse/i })
.first()
.click()
await expect(
missingNodeCard.getByText('MISSING_NODE_TYPE_IN_SUBGRAPH')
).not.toBeVisible()
})
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
await missingNodeCard
.getByRole('button', { name: /expand/i })
.first()
.click()
const locateButton = missingNodeCard.getByRole('button', {
name: /locate/i
})
await expect(locateButton.first()).toBeVisible()
// TODO: Add navigation assertion once subgraph node ID deduplication
// timing is fixed. Currently, collectMissingNodes runs before
// configure(), so execution IDs use pre-remapped node IDs that don't
// match the runtime graph. See PR #9510 / #8762.
})
})

View File

@@ -16,13 +16,21 @@ test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Search for and add the RecordAudio node
await comfyPage.searchBox.fillAndSelectFirstNode('RecordAudio')
await comfyPage.searchBox.fillAndSelectFirstNode('Record Audio', {
exact: true
})
await comfyPage.nextFrame()
// Verify the RecordAudio node was added
const recordAudioNodes =
await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')
expect(recordAudioNodes.length).toBe(1)
await expect
.poll(
async () =>
(await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')).length,
{
timeout: 5000
}
)
.toBe(1)
// Take a screenshot of the canvas with the RecordAudio node
await expect(comfyPage.canvas).toHaveScreenshot('record_audio_node.png')

View File

@@ -113,21 +113,48 @@ test.describe(
'reroute/single-native-reroute-default-workflow'
)
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
// since the link is a bezier curve and not a straight line.
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
const checkpointNode = await comfyPage.nodeOps.getNodeRefById(4)
const positiveClipNode = await comfyPage.nodeOps.getNodeRefById(6)
const negativeClipNode = await comfyPage.nodeOps.getNodeRefById(7)
const checkpointClipOutput = await checkpointNode.getOutput(1)
const positiveClipInput = await positiveClipNode.getInput(0)
const negativeClipInput = await negativeClipNode.getInput(0)
// Dynamically read the rendered link marker position from the canvas,
// targeting link 5 (CLIP from CheckpointLoaderSimple to negative CLIPTextEncode).
const middlePoint = await comfyPage.page.waitForFunction(() => {
const canvas = window['app']?.canvas
if (!canvas?.renderedPaths) return null
for (const segment of canvas.renderedPaths) {
if (segment.id === 5 && segment._pos) {
return { x: segment._pos[0], y: segment._pos[1] }
}
}
return null
})
const pos = await middlePoint.jsonValue()
if (!pos) throw new Error('Rendered midpoint for link 5 was not found')
// Click the middle point of the link to open the context menu.
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
await comfyPage.page.mouse.click(pos.x, pos.y)
// Click the "Delete" context menu option.
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_delete_from_midpoint_context_menu.png'
)
await expect
.poll(async () => ({
checkpointClipOutputLinks: await checkpointClipOutput.getLinkCount(),
positiveClipInputLinks: await positiveClipInput.getLinkCount(),
negativeClipInputLinks: await negativeClipInput.getLinkCount()
}))
.toEqual({
checkpointClipOutputLinks: 1,
positiveClipInputLinks: 1,
negativeClipInputLinks: 0
})
})
}
)

View File

@@ -229,6 +229,8 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
await cloneItem.click()
await expect(cloneItem).toHaveCount(0)
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(nodeCount + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(nodeCount + 1)
})
})

View File

@@ -19,13 +19,17 @@ test.describe('@canvas Selection Rectangle', () => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBe(totalCount)
})
test('Click empty space deselects all', async ({ comfyPage }) => {
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBeGreaterThan(0)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBeGreaterThan(0)
// Deselect by Ctrl+clicking the already-selected node (reliable cross-env)
await comfyPage.page
@@ -37,26 +41,26 @@ test.describe('@canvas Selection Rectangle', () => {
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
test('Single click selects one node', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: ['Control']
})
await comfyPage.nextFrame()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
})
test('Selected nodes have visual indicator', async ({ comfyPage }) => {
@@ -71,7 +75,7 @@ test.describe('@canvas Selection Rectangle', () => {
test('Drag-select rectangle selects multiple nodes', async ({
comfyPage
}) => {
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
// Use Ctrl+A to select all, which is functionally equivalent to
// drag-selecting the entire canvas and more reliable in CI
@@ -79,7 +83,9 @@ test.describe('@canvas Selection Rectangle', () => {
await comfyPage.nextFrame()
const totalCount = await comfyPage.vueNodes.getNodeCount()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(totalCount)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBe(totalCount)
expect(totalCount).toBeGreaterThan(1)
})
})

View File

@@ -194,6 +194,29 @@ test.describe('Assets sidebar - grid view display', () => {
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test('Displays svg outputs', async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory([
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'logo.svg',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
})
])
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.assetCards.locator('.pi-image')).toBeVisible()
})
})
// ==========================================================================

View File

@@ -1,16 +1,52 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
const bookmarksSettingId = 'Comfy.NodeLibrary.Bookmarks.V2'
const bookmarksCustomizationSettingId =
'Comfy.NodeLibrary.BookmarksCustomization'
type BookmarkCustomizationMap = Record<
string,
{
icon?: string
color?: string
}
>
async function expectBookmarks(comfyPage: ComfyPage, bookmarks: string[]) {
await expect
.poll(() => comfyPage.settings.getSetting<string[]>(bookmarksSettingId))
.toEqual(bookmarks)
}
async function expectBookmarkCustomization(
comfyPage: ComfyPage,
customization: BookmarkCustomizationMap
) {
await expect
.poll(() =>
comfyPage.settings.getSetting<BookmarkCustomizationMap>(
bookmarksCustomizationSettingId
)
)
.toEqual(customization)
}
async function renameInlineFolder(comfyPage: ComfyPage, newName: string) {
const renameInput = comfyPage.page.locator('.editable-text input')
await expect(renameInput).toBeVisible()
await renameInput.fill(newName)
await renameInput.press('Enter')
}
test.describe('Node library sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',
{}
)
await comfyPage.settings.setSetting(bookmarksSettingId, [])
await comfyPage.settings.setSetting(bookmarksCustomizationSettingId, {})
// Open the sidebar
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
@@ -21,14 +57,11 @@ test.describe('Node library sidebar', () => {
await tab.getFolder('sampling').click()
// Hover over a node to display the preview
const nodeSelector = '.p-tree-node-leaf'
const nodeSelector = tab.nodeSelector('KSampler (Advanced)')
await comfyPage.page.hover(nodeSelector)
// Verify the preview is displayed
const previewVisible = await comfyPage.page.isVisible(
'.node-lib-node-preview'
)
expect(previewVisible).toBe(true)
await expect(tab.nodePreview).toBeVisible()
const count = await comfyPage.nodeOps.getGraphNodesCount()
// Drag the node onto the canvas
@@ -48,9 +81,12 @@ test.describe('Node library sidebar', () => {
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector, {
targetPosition
})
await comfyPage.nextFrame()
// Verify the node is added to the canvas
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(count + 1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(count + 1)
})
test('Bookmark node', async ({ comfyPage }) => {
@@ -61,33 +97,29 @@ test.describe('Node library sidebar', () => {
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
// Verify the bookmark is added to the bookmarks tab
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['KSamplerAdvanced'])
await expectBookmarks(comfyPage, ['KSamplerAdvanced'])
// Verify the bookmark node with the same name is added to the tree.
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(2)
// Hover on the bookmark node to display the preview
await comfyPage.page.hover('.node-lib-bookmark-tree-explorer .tree-leaf')
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(true)
await expect(tab.nodePreview).toBeVisible()
})
test('Ignores unrecognized node', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo'])
await expectBookmarks(comfyPage, ['foo'])
await comfyPage.nextFrame()
const tab = comfyPage.menu.nodeLibraryTab
expect(await tab.getFolder('sampling').count()).toBe(1)
expect(await tab.getNode('foo').count()).toBe(0)
await expect(tab.getFolder('sampling')).toHaveCount(1)
await expect(tab.getNode('foo')).toHaveCount(0)
})
test('Displays empty bookmarks folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
expect(await tab.getFolder('foo').count()).toBe(1)
await expect(tab.getFolder('foo')).toHaveCount(1)
})
test('Can add new bookmark folder', async ({ comfyPage }) => {
@@ -97,17 +129,14 @@ test.describe('Node library sidebar', () => {
await textInput.waitFor({ state: 'visible' })
await textInput.fill('New Folder')
await textInput.press('Enter')
expect(await tab.getFolder('New Folder').count()).toBe(1)
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['New Folder/'])
await expect(tab.getFolder('New Folder')).toHaveCount(1)
await expectBookmarks(comfyPage, ['New Folder/'])
})
test('Can add nested bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByRole('menuitem', { name: 'New Folder' }).click()
@@ -116,59 +145,47 @@ test.describe('Node library sidebar', () => {
await textInput.fill('bar')
await textInput.press('Enter')
expect(await tab.getFolder('bar').count()).toBe(1)
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['foo/', 'foo/bar/'])
await expect(tab.getFolder('bar')).toHaveCount(1)
await expectBookmarks(comfyPage, ['foo/', 'foo/bar/'])
})
test('Can delete bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Delete').click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
await expectBookmarks(comfyPage, [])
})
test('Can rename bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu-item-label:has-text("Rename")')
.click()
await comfyPage.page.keyboard.insertText('bar')
await comfyPage.page.keyboard.press('Enter')
await renameInlineFolder(comfyPage, 'bar')
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
await expectBookmarks(comfyPage, ['bar/'])
})
test('Can add bookmark by dragging node to bookmark folder', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('sampling').click()
await comfyPage.page.dragAndDrop(
tab.nodeSelector('KSampler (Advanced)'),
tab.folderSelector('foo')
)
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['foo/', 'foo/KSamplerAdvanced'])
await expectBookmarks(comfyPage, ['foo/', 'foo/KSamplerAdvanced'])
})
test('Can add bookmark by clicking bookmark button', async ({
@@ -177,41 +194,36 @@ test.describe('Node library sidebar', () => {
const tab = comfyPage.menu.nodeLibraryTab
await tab.getFolder('sampling').click()
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['KSamplerAdvanced'])
await expectBookmarks(comfyPage, ['KSamplerAdvanced'])
})
test('Can unbookmark node (Top level bookmark)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
await comfyPage.settings.setSetting(bookmarksSettingId, [
'KSamplerAdvanced'
])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(1)
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
await expectBookmarks(comfyPage, [])
})
test('Can unbookmark node (Library node bookmark)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
await comfyPage.settings.setSetting(bookmarksSettingId, [
'KSamplerAdvanced'
])
const tab = comfyPage.menu.nodeLibraryTab
await tab.getFolder('sampling').click()
await expect(tab.getNode('KSampler (Advanced)')).toHaveCount(2)
await tab
.getNodeInFolder('KSampler (Advanced)', 'sampling')
.locator('.bookmark-button')
.click()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
await expectBookmarks(comfyPage, [])
})
test('Can customize icon', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Customize').click()
const dialog = comfyPage.page.getByRole('dialog', {
@@ -228,11 +240,7 @@ test.describe('Node library sidebar', () => {
await colorGroup.getByRole('button').nth(1).click()
await dialog.getByRole('button', { name: 'Confirm' }).click()
await comfyPage.nextFrame()
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({
await expectBookmarkCustomization(comfyPage, {
'foo/': {
icon: 'pi-folder',
color: '#007bff'
@@ -241,10 +249,9 @@ test.describe('Node library sidebar', () => {
})
// If color is left as default, it should not be saved
test('Can customize icon (default field)', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Customize').click()
const dialog = comfyPage.page.getByRole('dialog', {
@@ -255,11 +262,7 @@ test.describe('Node library sidebar', () => {
await iconGroup.getByRole('button').nth(1).click()
await dialog.getByRole('button', { name: 'Confirm' }).click()
await comfyPage.nextFrame()
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({
await expectBookmarkCustomization(comfyPage, {
'foo/': {
icon: 'pi-folder'
}
@@ -270,10 +273,9 @@ test.describe('Node library sidebar', () => {
comfyPage
}) => {
// Open customization dialog
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Customize').click()
@@ -302,84 +304,76 @@ test.describe('Node library sidebar', () => {
await comfyPage.nextFrame()
// Verify the color selection is saved
const setting = await comfyPage.settings.getSetting<
Record<string, { icon?: string; color?: string }>
>('Comfy.NodeLibrary.BookmarksCustomization')
await expect(setting).toHaveProperty(['foo/', 'color'])
await expect(setting['foo/'].color).not.toBeNull()
await expect(setting['foo/'].color).not.toBeUndefined()
await expect(setting['foo/'].color).not.toBe('')
await expect
.poll(async () => {
return (
(
await comfyPage.settings.getSetting<BookmarkCustomizationMap>(
bookmarksCustomizationSettingId
)
)['foo/']?.color ?? ''
)
})
.toMatch(/^#.+/)
})
test('Can rename customized bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',
{
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
await comfyPage.settings.setSetting(bookmarksCustomizationSettingId, {
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
)
})
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu-item-label:has-text("Rename")')
.click()
await comfyPage.page.keyboard.insertText('bar')
await comfyPage.page.keyboard.press('Enter')
await renameInlineFolder(comfyPage, 'bar')
await comfyPage.nextFrame()
await expect(async () => {
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual(['bar/'])
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({
'bar/': {
icon: 'pi-folder',
color: '#007bff'
await expect
.poll(async () => {
return {
bookmarks:
await comfyPage.settings.getSetting<string[]>(bookmarksSettingId),
customization:
await comfyPage.settings.getSetting<BookmarkCustomizationMap>(
bookmarksCustomizationSettingId
)
}
})
.toEqual({
bookmarks: ['bar/'],
customization: {
'bar/': {
icon: 'pi-folder',
color: '#007bff'
}
}
})
}).toPass({
timeout: 2_000
})
})
test('Can delete customized bookmark folder', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
'foo/'
])
await comfyPage.settings.setSetting(
'Comfy.NodeLibrary.BookmarksCustomization',
{
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
await comfyPage.settings.setSetting(bookmarksSettingId, ['foo/'])
await comfyPage.settings.setSetting(bookmarksCustomizationSettingId, {
'foo/': {
icon: 'pi-folder',
color: '#007bff'
}
)
})
const tab = comfyPage.menu.nodeLibraryTab
await expect(tab.getFolder('foo')).toBeVisible()
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('Delete').click()
await comfyPage.nextFrame()
expect(
await comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([])
expect(
await comfyPage.settings.getSetting(
'Comfy.NodeLibrary.BookmarksCustomization'
)
).toEqual({})
await expectBookmarks(comfyPage, [])
await expectBookmarkCustomization(comfyPage, {})
})
test('Can filter nodes in both trees', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
await comfyPage.settings.setSetting(bookmarksSettingId, [
'foo/',
'foo/KSamplerAdvanced',
'KSampler'

View File

@@ -0,0 +1,94 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import type { ComfyPage } from '../../fixtures/ComfyPage'
async function openVueNodeContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await comfyPage.contextMenu.openForVueNode(fixture.header)
}
test.describe(
'Subgraph Duplicate Independent Values',
{ tag: ['@slow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.vueNodes.waitForNodes()
})
test('Duplicated subgraphs maintain independent widget values', async ({
comfyPage
}) => {
const clipNodeTitle = 'CLIP Text Encode (Prompt)'
// Convert first CLIP Text Encode node to subgraph
await openVueNodeContextMenu(comfyPage, clipNodeTitle)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
await comfyPage.nextFrame()
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
// Duplicate the subgraph
await openVueNodeContextMenu(comfyPage, 'New Subgraph')
await comfyPage.contextMenu.clickMenuItemExact('Duplicate')
await comfyPage.nextFrame()
// Capture both subgraph node IDs
const subgraphNodes = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNodes).toHaveCount(2)
const nodeIds = await subgraphNodes.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute('data-node-id'))
.filter((id): id is string => id !== null)
)
const [nodeId1, nodeId2] = nodeIds
// Enter first subgraph, set text widget value
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea1.fill('subgraph1_value')
await expect(textarea1).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
// Enter second subgraph, set text widget value
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea2.fill('subgraph2_value')
await expect(textarea2).toHaveValue('subgraph2_value')
await comfyPage.subgraph.exitViaBreadcrumb()
// Re-enter first subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea1Again).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
// Re-enter second subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea2Again).toHaveValue('subgraph2_value')
})
}
)

View File

@@ -112,17 +112,17 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
@@ -194,7 +194,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
// Verify we're in a subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Test that Escape no longer exits subgraph
await comfyPage.page.keyboard.press('Escape')
@@ -206,7 +206,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
// Test that Alt+Q now exits subgraph
await comfyPage.page.keyboard.press('Alt+q')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
test('Escape prioritizes closing dialogs over exiting subgraph', async ({
@@ -240,12 +240,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
).not.toBeVisible()
// Should still be in subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Press Escape again - now should exit subgraph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})
@@ -372,7 +372,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
@@ -410,7 +410,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()

View File

@@ -41,8 +41,9 @@ test.describe(
await comfyPage.page.keyboard.press('Control+v')
await comfyPage.nextFrame()
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
expect(finalNodeCount).toBe(initialNodeCount + 1)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialNodeCount + 1)
})
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
@@ -63,15 +64,17 @@ test.describe(
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
const afterUndoCount = await comfyPage.subgraph.getNodeCount()
expect(afterUndoCount).toBe(initialCount - 1)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount - 1)
// Redo
await comfyPage.keyboard.redo()
await comfyPage.nextFrame()
const afterRedoCount = await comfyPage.subgraph.getNodeCount()
expect(afterRedoCount).toBe(initialCount)
await expect
.poll(() => comfyPage.subgraph.getNodeCount())
.toBe(initialCount)
})
}
)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { TestIds } from '../../fixtures/selectors'
import { fitToViewInstant } from '../../helpers/fitToView'
@@ -8,6 +9,30 @@ import {
getPromotedWidgetCount
} from '../../helpers/promotedWidgets'
async function expectPromotedWidgetNamesToContain(
comfyPage: ComfyPage,
nodeId: string,
widgetName: string
) {
await expect
.poll(() => getPromotedWidgetNames(comfyPage, nodeId), {
timeout: 5000
})
.toContain(widgetName)
}
async function expectPromotedWidgetCountToBeGreaterThan(
comfyPage: ComfyPage,
nodeId: string,
count: number
) {
await expect
.poll(() => getPromotedWidgetCount(comfyPage, nodeId), {
timeout: 5000
})
.toBeGreaterThan(count)
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -34,12 +59,10 @@ test.describe(
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames).toContain('seed')
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
// SubgraphNode should have widgets (promoted views)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
@@ -54,12 +77,9 @@ test.describe(
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
expect(promotedNames).toContain('text')
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
})
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
@@ -75,13 +95,12 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
expect(promotedNames).toContain('filename_prefix')
await expectPromotedWidgetNamesToContain(
comfyPage,
String(subgraphNode.id),
'filename_prefix'
)
})
})
@@ -160,7 +179,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -315,8 +334,7 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(widgetCount).toBeGreaterThan(0)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
})
test('Can un-promote a widget from inside a subgraph', async ({
@@ -352,8 +370,8 @@ test.describe(
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
@@ -382,8 +400,11 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
timeout: 5000
})
.toBeLessThan(initialWidgetCount)
})
})
@@ -451,9 +472,11 @@ test.describe(
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames.length).toBeGreaterThan(0)
expect(promotedNames).toContain('filename_prefix')
await expectPromotedWidgetNamesToContain(
comfyPage,
'5',
'filename_prefix'
)
})
test('Converting SaveImage to subgraph promotes its widgets', async ({
@@ -471,11 +494,12 @@ test.describe(
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
await expectPromotedWidgetNamesToContain(
comfyPage,
nodeId,
'filename_prefix'
)
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
})
})
@@ -600,8 +624,8 @@ test.describe(
)
await comfyPage.nextFrame()
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
@@ -617,13 +641,16 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'), {
timeout: 5000
})
.toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
@@ -635,8 +662,13 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(initialWidgetCount).toBeGreaterThan(0)
let initialWidgetCount = 0
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
timeout: 5000
})
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
@@ -649,8 +681,11 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'), {
timeout: 5000
})
.toBeLessThan(initialWidgetCount)
})
})
}

View File

@@ -190,11 +190,13 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
await expect(async () => {
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
}).toPass({ timeout: 5000 })
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()

View File

@@ -334,12 +334,12 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
})
})

View File

@@ -48,8 +48,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('input'))
.toBe(initialCount + 1)
})
test('Can add output slots to subgraph', async ({ comfyPage }) => {
@@ -67,8 +68,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount + 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount + 1)
})
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
@@ -86,8 +88,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('input')
expect(finalCount).toBe(initialCount - 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('input'))
.toBe(initialCount - 1)
})
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
@@ -105,8 +108,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const finalCount = await comfyPage.subgraph.getSlotCount('output')
expect(finalCount).toBe(initialCount - 1)
await expect
.poll(() => comfyPage.subgraph.getSlotCount('output'))
.toBe(initialCount - 1)
})
})
@@ -135,10 +139,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('input'))
.toBe(RENAMED_INPUT_NAME)
})
test('Can rename input slots via double-click', async ({ comfyPage }) => {
@@ -161,10 +164,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(RENAMED_INPUT_NAME)
expect(newInputName).not.toBe(initialInputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('input'))
.toBe(RENAMED_INPUT_NAME)
})
test('Can rename output slots via double-click', async ({ comfyPage }) => {
@@ -188,10 +190,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newOutputName = await comfyPage.subgraph.getSlotLabel('output')
expect(newOutputName).toBe(renamedOutputName)
expect(newOutputName).not.toBe(initialOutputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('output'))
.toBe(renamedOutputName)
})
test('Right-click context menu still works alongside double-click', async ({
@@ -220,10 +221,9 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame()
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
expect(newInputName).toBe(rightClickRenamedName)
expect(newInputName).not.toBe(initialInputLabel)
await expect
.poll(() => comfyPage.subgraph.getSlotLabel('input'))
.toBe(rightClickRenamedName)
})
test('Can double-click on slot label text to rename', async ({

View File

@@ -10,17 +10,14 @@ import { TestIds } from '../../../../fixtures/selectors'
const BYPASS_CLASS = /before:bg-bypass\/60/
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
await comfyPage.contextMenu.clickMenuItemExact(name)
await comfyPage.nextFrame()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await fixture.header.click()
await fixture.header.click({ button: 'right' })
const menu = comfyPage.contextMenu.primeVueMenu
await menu.waitFor({ state: 'visible' })
return menu
await comfyPage.contextMenu.openForVueNode(fixture.header)
return comfyPage.contextMenu.primeVueMenu
}
async function openMultiNodeContextMenu(
@@ -100,9 +97,9 @@ test.describe('Vue Node Context Menu', () => {
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test('should duplicate node via context menu', async ({ comfyPage }) => {
@@ -111,9 +108,9 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test('should pin and unpin node via context menu', async ({
@@ -128,7 +125,7 @@ test.describe('Vue Node Context Menu', () => {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await expect(fixture.pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
await expect.poll(() => nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = fixture.header
@@ -146,7 +143,7 @@ test.describe('Vue Node Context Menu', () => {
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(fixture.pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
await expect.poll(() => nodeRef.isPinned()).toBe(false)
})
test('should bypass node and remove bypass via context menu', async ({
@@ -158,7 +155,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
expect(await nodeRef.isBypassed()).toBe(true)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
@@ -166,7 +163,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
expect(await nodeRef.isBypassed()).toBe(false)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
@@ -209,6 +206,26 @@ test.describe('Vue Node Context Menu', () => {
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')
.first()
.waitFor({ state: 'visible' })
const [loadImageNode] =
await comfyPage.nodeOps.getNodeRefsByTitle('Load Image')
if (!loadImageNode) throw new Error('Load Image node not found')
await expect
.poll(
() =>
comfyPage.page.evaluate(
(nodeId) =>
window.app!.graph.getNodeById(nodeId)?.imgs?.length ?? 0,
loadImageNode.id
),
{ timeout: 5_000 }
)
.toBeGreaterThan(0)
})
test('should copy image to clipboard via context menu', async ({
@@ -218,13 +235,16 @@ test.describe('Vue Node Context Menu', () => {
await clickExactMenuItem(comfyPage, 'Copy Image')
// Verify the clipboard contains an image
const hasImage = await comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
expect(hasImage).toBe(true)
await expect
.poll(async () => {
return comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
})
.toBe(true)
})
test('should paste image to LoadImage node via context menu', async ({
@@ -377,9 +397,9 @@ test.describe('Vue Node Context Menu', () => {
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + nodeTitles.length)
})
test('should duplicate selected nodes via context menu', async ({
@@ -390,9 +410,9 @@ test.describe('Vue Node Context Menu', () => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + nodeTitles.length)
})
test('should pin and unpin selected nodes via context menu', async ({
@@ -423,7 +443,7 @@ test.describe('Vue Node Context Menu', () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(true)
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
@@ -432,7 +452,7 @@ test.describe('Vue Node Context Menu', () => {
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(false)
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)
@@ -504,9 +524,9 @@ test.describe('Vue Node Context Menu', () => {
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount - nodeTitles.length + 1
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount - nodeTitles.length + 1)
})
})
})

View File

@@ -29,23 +29,16 @@ test.describe('Vue Node Moving', () => {
expect(diffY).toBeGreaterThan(0)
}
test(
'should allow moving nodes by dragging',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const loadCheckpointHeaderPos =
await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
}
)
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage

View File

@@ -24,40 +24,43 @@ test.describe('Vue Node Selection', () => {
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Empty Latent Image').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
await comfyPage.page.getByText('KSampler').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
})
test(`should allow de-selecting nodes with ${name}+click`, async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await comfyPage.page.getByText('Load Checkpoint').click({
modifiers: [modifier]
})
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
})
}
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())
.toBeGreaterThan(0)
const initialCount = await comfyPage.vueNodes.getNodeCount()
expect(initialCount).toBeGreaterThan(0)
await comfyPage.canvas.press('Control+a')
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialCount)
await expect
.poll(() => comfyPage.vueNodes.getSelectedNodeCount())
.toBe(initialCount)
})
test('should select pinned node without dragging', async ({ comfyPage }) => {
@@ -73,7 +76,7 @@ test.describe('Vue Node Selection', () => {
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
const initialPos = await checkpointNodeHeader.boundingBox()
if (!initialPos) throw new Error('Failed to get header position')
@@ -87,6 +90,6 @@ test.describe('Vue Node Selection', () => {
if (!finalPos) throw new Error('Failed to get header position after drag')
expect(finalPos).toEqual(initialPos)
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
})

View File

@@ -84,24 +84,25 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Should refresh combo values of nodes with v2 combo input spec', async ({
comfyPage
}) => {
const getComboValues = async () =>
comfyPage.page.evaluate(() => {
return window
.app!.graph!.nodes.find(
(node) => node.title === 'Node With V2 Combo Input'
)!
.widgets!.find((widget) => widget.name === 'combo_input')!.options
.values
})
await comfyPage.workflow.loadWorkflow('inputs/node_with_v2_combo_input')
// click canvas to focus
await comfyPage.page.mouse.click(400, 300)
// press R to trigger refresh
await comfyPage.page.keyboard.press('r')
// wait for nodes' widgets to be updated
await comfyPage.page.mouse.click(400, 300)
await comfyPage.nextFrame()
// get the combo widget's values
const comboValues = await comfyPage.page.evaluate(() => {
return window
.app!.graph!.nodes.find(
(node) => node.title === 'Node With V2 Combo Input'
)!
.widgets!.find((widget) => widget.name === 'combo_input')!.options
.values
})
expect(comboValues).toEqual(['A', 'B'])
await expect
.poll(() => getComboValues(), { timeout: 5_000 })
.toEqual(['A', 'B'])
})
})
@@ -125,6 +126,7 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
window.widgetValue = undefined
const widget = window.app!.graph!.nodes[0].widgets![0]
widget.callback = (value: number) => {
window.widgetValue = value
@@ -133,9 +135,11 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
expect(
await comfyPage.page.evaluate(() => window.widgetValue)
).toBeDefined()
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
timeout: 2_000
})
.toBeDefined()
})
})
@@ -146,6 +150,7 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const widget = await node.getWidget(0)
await comfyPage.page.evaluate(() => {
window.widgetValue = undefined
const widget = window.app!.graph!.nodes[0].widgets![0]
widget.callback = (value: number) => {
window.widgetValue = value
@@ -154,9 +159,11 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
expect(
await comfyPage.page.evaluate(() => window.widgetValue)
).toBeDefined()
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue), {
timeout: 2_000
})
.toBeDefined()
})
})
@@ -209,8 +216,7 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
// Expect the filename combo value to be updated
const fileComboWidget = await loadImageNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
await expect.poll(() => fileComboWidget.getValue()).toBe('image32x32.webp')
})
test('Can change image by changing the filename combo value', async ({
@@ -248,8 +254,7 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
)
// Expect the filename combo value to be updated
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
await expect.poll(() => fileComboWidget.getValue()).toBe('image32x32.webp')
})
test('Displays buttons when viewing single image of batch', async ({
comfyPage
@@ -301,8 +306,9 @@ test.describe(
// Expect the filename combo value to be updated
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toContain('animated_webp.webp')
await expect
.poll(() => fileComboWidget.getValue())
.toContain('animated_webp.webp')
})
test('Can preview saved animated webp image', async ({ comfyPage }) => {
@@ -317,9 +323,20 @@ test.describe(
// Drag and drop image file onto the load animated webp node
await comfyPage.dragDrop.dragAndDropFile('animated_webp.webp', {
dropPosition: { x, y }
dropPosition: { x, y },
waitForUpload: true
})
await comfyPage.nextFrame()
await expect
.poll(
() =>
comfyPage.page.evaluate(
(loadId) => window.app!.nodeOutputs[loadId]?.images?.length ?? 0,
loadAnimatedWebpNode.id
),
{ timeout: 10_000 }
)
.toBeGreaterThan(0)
// Get the SaveAnimatedWEBP node
const saveNodes =
@@ -337,11 +354,23 @@ test.describe(
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('.dom-widget').locator('img')
).toHaveCount(2, { timeout: 10_000 })
await expect
.poll(
() =>
comfyPage.page.evaluate(
([loadId, saveId]) => {
const graph = window.app!.graph
return [loadId, saveId].map(
(nodeId) => (graph.getNodeById(nodeId)?.imgs?.length ?? 0) > 0
)
},
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
),
{ timeout: 10_000 }
)
.toEqual([true, true])
})
}
)

View File

@@ -2,8 +2,45 @@ import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function getNodeOutputImageCount(
comfyPage: ComfyPage,
nodeId: string
): Promise<number> {
return await comfyPage.page.evaluate(
(id) => window.app!.nodeOutputs?.[id]?.images?.length ?? 0,
nodeId
)
}
async function getWidgetValueSnapshot(
comfyPage: ComfyPage
): Promise<Record<string, Array<{ name: string; value: unknown }>>> {
return await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, Array<{ name: string; value: unknown }>> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
}
async function getLinkCount(comfyPage: ComfyPage): Promise<number> {
return await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
}
test.describe('Workflow Persistence', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -71,7 +108,7 @@ test.describe('Workflow Persistence', () => {
const firstNode = await comfyPage.nodeOps.getFirstNodeRef()
expect(firstNode).toBeTruthy()
const nodeId = firstNode!.id
const nodeId = String(firstNode!.id)
// Simulate node outputs as if execution completed
await comfyPage.page.evaluate((id) => {
@@ -91,24 +128,20 @@ test.describe('Workflow Persistence', () => {
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await comfyPage.nextFrame()
const outputsBefore = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsBefore).toBeTruthy()
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
await tab.switchToWorkflow('outputs-test')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
const outputsAfter = await comfyPage.page.evaluate((id) => {
return window.app!.nodeOutputs?.[id]
}, String(nodeId))
expect(outputsAfter).toBeTruthy()
expect(outputsAfter?.images).toBeDefined()
await expect
.poll(() => getNodeOutputImageCount(comfyPage, nodeId), {
timeout: 5_000
})
.toBe(1)
})
test('Loading a new workflow cleanly replaces the previous graph', async ({
@@ -149,43 +182,21 @@ test.describe('Workflow Persistence', () => {
// Read widget values via page.evaluate — these are internal LiteGraph
// state not exposed through DOM
const widgetValuesBefore = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
const widgetValuesBefore = await getWidgetValueSnapshot(comfyPage)
expect(Object.keys(widgetValuesBefore).length).toBeGreaterThan(0)
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
await tab.switchToWorkflow('widget-state-test')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
const widgetValuesAfter = await comfyPage.page.evaluate(() => {
const nodes = window.app!.graph.nodes
const results: Record<string, unknown[]> = {}
for (const node of nodes) {
if (node.widgets && node.widgets.length > 0) {
results[node.id] = node.widgets.map((w) => ({
name: w.name,
value: w.value
}))
}
}
return results
})
expect(widgetValuesAfter).toEqual(widgetValuesBefore)
await expect
.poll(() => getWidgetValueSnapshot(comfyPage), {
timeout: 5_000
})
.toEqual(widgetValuesBefore)
})
test('API format workflow with missing node types partially loads', async ({
@@ -234,10 +245,10 @@ test.describe('Workflow Persistence', () => {
button: 'middle',
position: { x: 100, y: 100 }
})
await comfyPage.nextFrame()
const nodeCountAfter = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountAfter).toBe(initialNodeCount)
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toBe(initialNodeCount)
})
test('Exported workflow does not contain transient blob: URLs', async ({
@@ -300,27 +311,85 @@ test.describe('Workflow Persistence', () => {
await tab.open()
// Link count requires internal graph state — not exposed via DOM
const linkCountBefore = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
})
const linkCountBefore = await getLinkCount(comfyPage)
expect(linkCountBefore).toBeGreaterThan(0)
await comfyPage.menu.topbar.saveWorkflow('links-test')
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.workflow.waitForWorkflowIdle()
await tab.switchToWorkflow('links-test')
await comfyPage.workflow.waitForWorkflowIdle()
const linkCountAfter = await comfyPage.page.evaluate(() => {
return window.app!.graph.links
? Object.keys(window.app!.graph.links).length
: 0
await expect
.poll(() => getLinkCount(comfyPage), { timeout: 5_000 })
.toBe(linkCountBefore)
})
test('Closing an unmodified inactive tab preserves both workflows', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — closing inactive tab could corrupt the persisted file'
})
expect(linkCountAfter).toBe(linkCountBefore)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const suffix = Date.now().toString(36)
const nameA = `test-A-${suffix}`
const nameB = `test-B-${suffix}`
// Save the default workflow as A
await comfyPage.menu.topbar.saveWorkflow(nameA)
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
// 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, {}))
})
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountB).toBe(nodeCountA + 1)
// Switch to A (making B inactive and unmodified)
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Close inactive B via middle-click — no save dialog expected
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
button: 'middle'
})
await comfyPage.nextFrame()
// A should still have its own content
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Reopen B from saved list
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem(nameB).dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
// B should have its original content, not A's
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
})
test('Closing an inactive tab with save preserves its own content', async ({

View File

@@ -93,9 +93,7 @@ test.describe('Zoom Controls', { tag: '@canvas' }, () => {
const input = comfyPage.page
.getByTestId(TestIds.canvas.zoomPercentageInput)
.locator('input')
await input.focus()
await comfyPage.page.keyboard.press('Control+a')
await input.pressSequentially('100')
await input.fill('100')
await input.press('Enter')
await comfyPage.nextFrame()

6
codecov.yml Normal file
View File

@@ -0,0 +1,6 @@
comment:
layout: 'header, diff, flags, files'
behavior: default
require_changes: false
require_base: false
require_head: true

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.14",
"version": "1.43.15",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -58,7 +58,6 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/ingest-types": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
@@ -123,6 +122,7 @@
"zod-validation-error": "catalog:"
},
"devDependencies": {
"@comfyorg/ingest-types": "workspace:*",
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",

View File

@@ -1 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -62,7 +62,8 @@ describe('formatUtil', () => {
{ filename: 'animation.gif', expected: 'image' },
{ filename: 'web.webp', expected: 'image' },
{ filename: 'bitmap.bmp', expected: 'image' },
{ filename: 'modern.avif', expected: 'image' }
{ filename: 'modern.avif', expected: 'image' },
{ filename: 'logo.svg', expected: 'image' }
]
it.for(imageTestCases)(

View File

@@ -542,7 +542,8 @@ const IMAGE_EXTENSIONS = [
'bmp',
'avif',
'tif',
'tiff'
'tiff',
'svg'
] as const
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const

View File

@@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test'
import type { PlaywrightTestConfig } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
? {

6
pnpm-lock.yaml generated
View File

@@ -419,9 +419,6 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
'@comfyorg/ingest-types':
specifier: workspace:*
version: link:packages/ingest-types
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types
@@ -609,6 +606,9 @@ importers:
specifier: 'catalog:'
version: 3.3.0(zod@3.25.76)
devDependencies:
'@comfyorg/ingest-types':
specifier: workspace:*
version: link:packages/ingest-types
'@eslint/js':
specifier: 'catalog:'
version: 9.39.1

View File

@@ -170,7 +170,8 @@ defineExpose({ handleDragDrop })
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
: undefined
"
data-testid="builder-widget-item"
:data-testid="builderMode ? 'builder-widget-item' : 'app-mode-widget-item'"
:data-widget-key="key"
>
<div
:class="

View File

@@ -1,5 +1,8 @@
<template>
<div class="comfy-error-report flex flex-col gap-4">
<div
data-testid="error-dialog"
class="comfy-error-report flex flex-col gap-4"
>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
@@ -14,11 +17,17 @@
</template>
<div class="flex justify-center gap-2">
<Button v-show="!reportOpen" variant="textonly" @click="showReport">
<Button
v-show="!reportOpen"
data-testid="error-dialog-show-report"
variant="textonly"
@click="showReport"
>
{{ $t('g.showReport') }}
</Button>
<Button
v-show="!reportOpen"
data-testid="error-dialog-contact-support"
variant="textonly"
@click="showContactSupport"
>
@@ -40,7 +49,11 @@
:repo-owner="repoOwner"
:repo-name="repoName"
/>
<Button v-if="reportOpen" @click="copyReportToClipboard">
<Button
v-if="reportOpen"
data-testid="error-dialog-copy-report"
@click="copyReportToClipboard"
>
<i class="pi pi-copy" />
{{ $t('g.copyToClipboard') }}
</Button>

View File

@@ -1,5 +1,9 @@
<template>
<Button variant="secondary" @click="openGitHubIssues">
<Button
data-testid="error-dialog-find-issues"
variant="secondary"
@click="openGitHubIssues"
>
<i class="pi pi-github" />
{{ $t('g.findIssues') }}
</Button>

View File

@@ -143,14 +143,14 @@ describe('Autogrow', () => {
test('Can add autogrow with min input count', () => {
const node = testNode()
addAutogrow(node, { min: 4, input: inputsSpec })
expect(node.inputs.length).toBe(4)
expect(node.inputs.length).toBe(5)
})
test('Adding connections will cause growth up to max', () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test', max: 3 })
expect(node.inputs.length).toBe(1)
expect(node.inputs.length).toBe(2)
connectInput(node, 0, graph)
expect(node.inputs.length).toBe(2)
@@ -159,7 +159,7 @@ describe('Autogrow', () => {
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(3)
})
test('Removing connections decreases to min', async () => {
test('Removing connections decreases to min + 1', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
@@ -204,9 +204,9 @@ describe('Autogrow', () => {
'0.a0',
'0.a1',
'0.a2',
'1.b0',
'1.b1',
'1.b2',
'2.b0',
'2.b1',
'2.b2',
'aa'
])
})

View File

@@ -511,7 +511,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
lastInput
)
}
const removalChecks = groupInputs.slice((min - 1) * stride)
const removalChecks = groupInputs.slice(min * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
@@ -597,6 +597,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i === 0 || i < min; i++)
for (let i = 0; i === 0 || i < min + 1; i++)
addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -0,0 +1,157 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
name: 'Civitai',
hostnames: ['civitai.com'],
fetchMetadata: vi.fn()
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
name: 'HuggingFace',
hostnames: ['huggingface.co'],
fetchMetadata: vi.fn()
}
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path: string) => path)
}
}))
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback,
t: (key: string) => key,
te: () => false,
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const asyncResponse: AsyncUploadResponse = {
type: 'async',
task: {
task_id: 'task-123',
status: 'created',
message: 'Download queued'
}
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
// Simulate WebSocket: download completes
const detail = {
task_id: 'task-123',
asset_id: 'asset-456',
asset_name: 'model.safetensors',
bytes_total: 1000,
bytes_downloaded: 1000,
progress: 100,
status: 'completed' as const
}
const event = new CustomEvent('asset_download', { detail })
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
expect(handler).toBeDefined()
handler!(event)
await nextTick()
expect(wizard.uploadStatus.value).toBe('success')
})
it('updates uploadStatus to error when async download fails', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const asyncResponse: AsyncUploadResponse = {
type: 'async',
task: {
task_id: 'task-fail',
status: 'created',
message: 'Download queued'
}
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
// Simulate WebSocket: download fails
const { api } = await import('@/scripts/api')
const handler = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_download')?.[1] as
| ((e: CustomEvent) => void)
| undefined
const failEvent = new CustomEvent('asset_download', {
detail: {
task_id: 'task-fail',
asset_id: '',
asset_name: 'model.safetensors',
bytes_total: 1000,
bytes_downloaded: 500,
progress: 50,
status: 'failed' as const,
error: 'Network error'
}
})
expect(handler).toBeDefined()
handler!(failEvent)
await nextTick()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadError.value).toBe('Network error')
})
})

View File

@@ -36,6 +36,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
url: '',
@@ -203,6 +204,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
if (!canUploadModel.value) {
return false
}
@@ -247,6 +249,44 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
)
}
uploadStatus.value = 'processing'
stopAsyncWatch?.()
let resolved = false
const stop = watch(
() =>
assetDownloadStore.downloadList.find(
(d) => d.taskId === result.task.task_id
)?.status,
async (status) => {
if (status === 'completed') {
resolved = true
uploadStatus.value = 'success'
await refreshModelCaches()
stopAsyncWatch?.()
stopAsyncWatch = undefined
} else if (status === 'failed') {
resolved = true
const download = assetDownloadStore.downloadList.find(
(d) => d.taskId === result.task.task_id
)
uploadStatus.value = 'error'
uploadError.value =
download?.error ||
t('assetBrowser.downloadFailed', {
name: download?.assetName || ''
})
stopAsyncWatch?.()
stopAsyncWatch = undefined
}
},
{ immediate: true }
)
if (resolved) {
stop()
stopAsyncWatch = undefined
} else {
stopAsyncWatch = stop
}
} else {
uploadStatus.value = 'success'
await refreshModelCaches()
@@ -271,6 +311,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
function resetWizard() {
stopAsyncWatch?.()
stopAsyncWatch = undefined
currentStep.value = 1
isFetchingMetadata.value = false
isUploading.value = false

View File

@@ -14,6 +14,11 @@ export function appendCloudResParam(
filename?: string
): void {
if (!isCloud) return
if (filename && getMediaTypeFromFilename(filename) !== 'image') return
if (
filename &&
(getMediaTypeFromFilename(filename) !== 'image' ||
filename.toLowerCase().endsWith('.svg'))
)
return
params.set('res', '512')
}

View File

@@ -38,6 +38,7 @@
<!-- Asset unsupported group notice -->
<div
v-if="isCloud && !group.isAssetSupported"
data-testid="missing-model-import-unsupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i

View File

@@ -16,6 +16,7 @@
</p>
<Button
data-testid="missing-model-copy-name"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
@@ -32,6 +33,7 @@
<Button
v-if="!isCloud && model.representative.url && !isAssetSupported"
data-testid="missing-model-copy-url"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@@ -62,6 +64,7 @@
<Button
v-if="model.referencingNodes.length > 0"
data-testid="missing-model-expand"
variant="textonly"
size="icon-sm"
:aria-label="
@@ -106,6 +109,7 @@
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
@@ -148,6 +152,7 @@
class="flex w-full items-start py-1"
>
<Button
data-testid="missing-model-download"
variant="secondary"
size="md"
class="flex w-full flex-1"

View File

@@ -88,10 +88,9 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import { isDOMWidget } from '@/scripts/domWidget'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
@@ -108,12 +107,14 @@ const props = defineProps<{
// Audio element ref
const audioRef = ref<HTMLAudioElement>()
// Keep track of the last uploaded path as a backup
let lastUploadedPath = ''
// Composables
const recorder = useAudioRecorder({
onRecordingComplete: handleRecordingComplete,
onRecordingComplete: async (blob) => handleRecordingComplete(blob),
onStop: () => {
pauseTimer()
waveform.stopWaveform()
waveform.dispose()
},
onError: () => {
useToastStore().addAlert(
t('g.micPermissionDenied') || 'Microphone permission denied'
@@ -154,20 +155,33 @@ const { isPlaying, audioElementKey } = playback
// Computed for waveform animation
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
const modelValue = defineModel<string>({ default: '' })
const litegraphNode = computed(() => {
if (!props.nodeId || !app.canvas.graph) return null
return app.canvas.graph.getNodeById(props.nodeId) as LGraphNode | null
})
async function handleRecordingComplete(blob: Blob) {
try {
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
modelValue.value = path
lastUploadedPath = path
} catch (e) {
useToastStore().addAlert('Failed to upload recorded audio')
function handleRecordingComplete(blob: Blob) {
// Create a widget-owned blob URL (independent of useAudioRecorder's
// recordedURL which gets revoked on re-record or unmount) and set it
// on the litegraph audioUI DOM widget's element. The original
// serializeValue (in uploadAudio.ts) reads element.src, fetches the
// blob, and uploads at serialization time.
const node = litegraphNode.value
if (!node?.widgets) return
for (const w of node.widgets) {
if (
!(
isDOMWidget<HTMLAudioElement, string>(w) &&
w.element instanceof HTMLAudioElement
)
)
continue
if (w.element.src.startsWith('blob:')) {
URL.revokeObjectURL(w.element.src)
}
w.element.src = URL.createObjectURL(blob)
break
}
}
@@ -198,8 +212,6 @@ async function handleStartRecording() {
function handleStopRecording() {
recorder.stopRecording()
pauseTimer()
waveform.stopWaveform()
}
async function handlePlayRecording() {
@@ -258,42 +270,8 @@ function handlePlaybackEnded() {
}
}
// Serialization function for workflow execution
async function serializeValue() {
if (isRecording.value && recorder.mediaRecorder.value) {
recorder.mediaRecorder.value.stop()
await new Promise((resolve, reject) => {
let attempts = 0
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
const checkRecording = () => {
if (!isRecording.value && modelValue.value) {
resolve(undefined)
} else if (++attempts >= maxAttempts) {
reject(new Error('Recording serialization timeout after 5 seconds'))
} else {
setTimeout(checkRecording, 100)
}
}
checkRecording()
})
}
return modelValue.value || lastUploadedPath || ''
}
function registerWidgetSerialization() {
const node = litegraphNode.value
if (!node?.widgets) return
const targetWidget = node.widgets.find((w: IBaseWidget) => w.name === 'audio')
if (targetWidget) {
targetWidget.serializeValue = serializeValue
}
}
onMounted(() => {
waveform.initWaveform()
registerWidgetSerialization()
})
onUnmounted(() => {

View File

@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import { useAudioRecorder } from '@/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder'
const MockMediaRecorder = vi.hoisted(() =>
vi.fn(
class {
state = 'recording'
start = vi.fn()
onstop: (() => void) | null = null
ondataavailable: ((e: { data: Blob }) => void) | null = null
stop = vi.fn(() => {
this.state = 'inactive'
this.onstop?.()
})
}
)
)
vi.mock('extendable-media-recorder', () => ({
MediaRecorder: MockMediaRecorder
}))
vi.mock('@/services/audioService', () => ({
useAudioService: () => ({
registerWavEncoder: vi.fn().mockResolvedValue(undefined)
})
}))
function createMockTrack() {
return { kind: 'audio', readyState: 'live', stop: vi.fn() }
}
function createMockStream(tracks = [createMockTrack()]) {
return { getTracks: () => tracks } as unknown as MediaStream
}
const mockGetUserMedia = vi.fn()
vi.stubGlobal('navigator', {
mediaDevices: { getUserMedia: mockGetUserMedia }
})
function recorderInstance() {
return MockMediaRecorder.mock.instances[0]
}
describe('useAudioRecorder', () => {
beforeEach(() => {
MockMediaRecorder.mockClear()
mockGetUserMedia.mockResolvedValue(createMockStream())
})
afterEach(() => {
vi.restoreAllMocks()
})
it('starts recording and sets isRecording to true', async () => {
const scope = effectScope()
const { isRecording, startRecording } = scope.run(() => useAudioRecorder())!
await startRecording()
expect(isRecording.value).toBe(true)
expect(mockGetUserMedia).toHaveBeenCalledWith({ audio: true })
expect(recorderInstance().start).toHaveBeenCalledWith(100)
scope.stop()
})
it('stops recording and releases microphone tracks', async () => {
const mockStream = createMockStream()
mockGetUserMedia.mockResolvedValue(mockStream)
const scope = effectScope()
const { isRecording, startRecording, stopRecording } = scope.run(() =>
useAudioRecorder()
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(isRecording.value).toBe(false))
const tracks = mockStream.getTracks()
expect(tracks[0].stop).toHaveBeenCalled()
scope.stop()
})
it('creates blob URL after stopping', async () => {
const scope = effectScope()
const { recordedURL, startRecording, stopRecording } = scope.run(() =>
useAudioRecorder()
)!
await startRecording()
recorderInstance().ondataavailable?.({
data: new Blob(['audio'], { type: 'audio/wav' })
})
stopRecording()
await vi.waitFor(() => expect(recordedURL.value).not.toBeNull())
expect(recordedURL.value).toMatch(/^blob:/)
scope.stop()
})
it('calls onRecordingComplete with blob after stop', async () => {
const onComplete = vi.fn().mockResolvedValue(undefined)
const scope = effectScope()
const { startRecording, stopRecording } = scope.run(() =>
useAudioRecorder({ onRecordingComplete: onComplete })
)!
await startRecording()
recorderInstance().ondataavailable?.({
data: new Blob(['chunk'], { type: 'audio/wav' })
})
stopRecording()
await vi.waitFor(() => expect(onComplete).toHaveBeenCalled())
expect(onComplete).toHaveBeenCalledWith(expect.any(Blob))
scope.stop()
})
it('releases mic BEFORE calling onRecordingComplete', async () => {
const mockStream = createMockStream()
mockGetUserMedia.mockResolvedValue(mockStream)
const tracks = mockStream.getTracks()
let micReleasedBeforeCallback = false
const onComplete = vi.fn().mockImplementation(async () => {
micReleasedBeforeCallback =
vi.mocked(tracks[0].stop).mock.calls.length > 0
})
const scope = effectScope()
const { startRecording, stopRecording } = scope.run(() =>
useAudioRecorder({ onRecordingComplete: onComplete })
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(onComplete).toHaveBeenCalled())
expect(micReleasedBeforeCallback).toBe(true)
scope.stop()
})
it('calls onStop synchronously during cleanup', async () => {
const onStop = vi.fn()
const scope = effectScope()
const { startRecording, stopRecording } = scope.run(() =>
useAudioRecorder({ onStop })
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(onStop).toHaveBeenCalled())
scope.stop()
})
it('sets mediaRecorder to null in cleanup', async () => {
const scope = effectScope()
const { mediaRecorder, startRecording, stopRecording } = scope.run(() =>
useAudioRecorder()
)!
await startRecording()
expect(mediaRecorder.value).not.toBeNull()
stopRecording()
await vi.waitFor(() => expect(mediaRecorder.value).toBeNull())
scope.stop()
})
it('calls onError when getUserMedia fails', async () => {
mockGetUserMedia.mockRejectedValue(new Error('Permission denied'))
const onError = vi.fn()
const scope = effectScope()
const { isRecording, startRecording } = scope.run(() =>
useAudioRecorder({ onError })
)!
await expect(startRecording()).rejects.toThrow('Permission denied')
expect(onError).toHaveBeenCalled()
expect(isRecording.value).toBe(false)
scope.stop()
})
it('dispose stops recording and revokes blob URL', async () => {
const revokeURL = vi.spyOn(URL, 'revokeObjectURL')
const scope = effectScope()
const { recordedURL, startRecording, stopRecording, dispose } = scope.run(
() => useAudioRecorder()
)!
await startRecording()
stopRecording()
await vi.waitFor(() => expect(recordedURL.value).not.toBeNull())
const blobUrl = recordedURL.value
dispose()
expect(recordedURL.value).toBeNull()
expect(revokeURL).toHaveBeenCalledWith(blobUrl)
scope.stop()
})
it('cleanup is idempotent when called on inactive recorder', () => {
const scope = effectScope()
const { isRecording, stopRecording } = scope.run(() => useAudioRecorder())!
expect(isRecording.value).toBe(false)
stopRecording()
expect(isRecording.value).toBe(false)
scope.stop()
})
})

View File

@@ -5,6 +5,7 @@ import { useAudioService } from '@/services/audioService'
interface AudioRecorderOptions {
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
onStop?: () => void
onError?: (error: Error) => void
}
@@ -48,12 +49,12 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) {
}
recordedURL.value = URL.createObjectURL(blob)
cleanup()
// Notify completion
if (options.onRecordingComplete) {
await options.onRecordingComplete(blob)
}
cleanup()
}
// Start recording
@@ -77,11 +78,14 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) {
function cleanup() {
isRecording.value = false
mediaRecorder.value = null
if (stream.value) {
stream.value.getTracks().forEach((track) => track.stop())
stream.value = null
}
options.onStop?.()
}
function dispose() {