mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-08 13:30:00 +00:00
Compare commits
19 Commits
range-edit
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b494392265 | ||
|
|
3f375bea9c | ||
|
|
c084089fc8 | ||
|
|
4cb83353cb | ||
|
|
d73c4406ed | ||
|
|
ccdde8697c | ||
|
|
194baf7aee | ||
|
|
5770837e07 | ||
|
|
4078f8be8f | ||
|
|
ff0453416a | ||
|
|
fd9c67ade8 | ||
|
|
83f4e7060a | ||
|
|
31c789c242 | ||
|
|
1b05927ff4 | ||
|
|
97853aa8b0 | ||
|
|
ac922fe6aa | ||
|
|
6f8e58bfa5 | ||
|
|
6cd3b59d5f | ||
|
|
0b83926c3e |
1
.github/workflows/ci-tests-unit.yaml
vendored
1
.github/workflows/ci-tests-unit.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
2
.github/workflows/release-version-bump.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
99
browser_tests/FLAKE_PREVENTION_RULES.md
Normal file
99
browser_tests/FLAKE_PREVENTION_RULES.md
Normal 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
|
||||
47
browser_tests/assets/3d/load3d_node.json
Normal file
47
browser_tests/assets/3d/load3d_node.json
Normal 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
|
||||
}
|
||||
85
browser_tests/assets/missing/missing_models_with_nodes.json
Normal file
85
browser_tests/assets/missing/missing_models_with_nodes.json
Normal 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
|
||||
}
|
||||
72
browser_tests/assets/missing/missing_nodes_and_media.json
Normal file
72
browser_tests/assets/missing/missing_nodes_and_media.json
Normal 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
|
||||
}
|
||||
197
browser_tests/assets/subgraphs/basic-subgraph-zero-uuid.json
Normal file
197
browser_tests/assets/subgraphs/basic-subgraph-zero-uuid.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [144.15234375, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
306
browser_tests/fixtures/data/assetFixtures.ts
Normal file
306
browser_tests/fixtures/data/assetFixtures.ts
Normal 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`
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
import { AppModeWidgetHelper } from './AppModeWidgetHelper'
|
||||
import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
@@ -13,18 +14,31 @@ export class AppModeHelper {
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.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
|
||||
@@ -91,6 +105,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").
|
||||
|
||||
93
browser_tests/fixtures/helpers/AppModeWidgetHelper.ts
Normal file
93
browser_tests/fixtures/helpers/AppModeWidgetHelper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
317
browser_tests/fixtures/helpers/AssetHelper.ts
Normal file
317
browser_tests/fixtures/helpers/AssetHelper.ts
Normal 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)
|
||||
}
|
||||
@@ -1,9 +1,36 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { NodeReference } from '../utils/litegraphUtils'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
@@ -99,41 +126,69 @@ export class BuilderSelectHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async selectInputWidget(node: NodeReference) {
|
||||
/**
|
||||
* Click a widget on the canvas to select it as a builder input.
|
||||
* @param nodeTitle The displayed title of the node.
|
||||
* @param widgetName The widget name to click.
|
||||
*/
|
||||
async selectInputWidget(nodeTitle: string, widgetName: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await this.page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await this.page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async selectOutputNode() {
|
||||
const saveImageNodeId = await this.page.evaluate(() => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
return node ? String(node.id) : null
|
||||
})
|
||||
if (!saveImageNodeId)
|
||||
throw new Error('SaveImage/PreviewImage node not found')
|
||||
const saveImageRef =
|
||||
await this.comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
}
|
||||
|
||||
const canvasBox = await this.page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await this.page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
get previewWidgetLabels(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an IoItem from one index to another in the inputs step.
|
||||
* Items are identified by their 0-based position among visible IoItems.
|
||||
*/
|
||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a widget item from one index to another in the preview/arrange step.
|
||||
*/
|
||||
async dragPreviewItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.widgetItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an output node on the canvas to select it as a builder output.
|
||||
* @param nodeTitle The displayed title of the output node.
|
||||
*/
|
||||
async selectOutputNode(nodeTitle: string) {
|
||||
await this.comfyPage.canvasOps.setScale(1)
|
||||
const nodeRef = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
)[0]
|
||||
if (!nodeRef) throw new Error(`Node ${nodeTitle} not found`)
|
||||
await nodeRef.centerOnNode()
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -111,7 +122,12 @@ export const TestIds = {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
opensAs: 'builder-opens-as',
|
||||
widgetItem: 'builder-widget-item',
|
||||
widgetLabel: 'builder-widget-label'
|
||||
},
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
@@ -149,6 +165,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
(typeof TestIds.templates)[keyof typeof TestIds.templates],
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { comfyExpect } from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
@@ -13,55 +19,97 @@ import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
* @param prepareGraph - Optional callback to transform the graph before
|
||||
* entering builder. Receives the KSampler node ref and returns the
|
||||
* input node title and widget names to select.
|
||||
* Defaults to KSampler with its first widget.
|
||||
* Mutually exclusive with widgetNames.
|
||||
* @param widgetNames - Widget names to select from the KSampler node.
|
||||
* Only used when prepareGraph is not provided.
|
||||
* Mutually exclusive with prepareGraph.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
prepareGraph?: (ksampler: NodeReference) => Promise<BuilderSetupResult>,
|
||||
widgetNames?: string[]
|
||||
): Promise<void> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
const { inputNodeTitle, widgetNames: inputWidgets } = prepareGraph
|
||||
? await prepareGraph(ksampler)
|
||||
: { inputNodeTitle: 'KSampler', widgetNames: widgetNames ?? ['seed'] }
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.selectInputWidget(inputNode)
|
||||
|
||||
for (const name of inputWidgets) {
|
||||
await appMode.select.selectInputWidget(inputNodeTitle, name)
|
||||
}
|
||||
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
|
||||
return inputNode
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
): Promise<void> {
|
||||
await setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
return {
|
||||
inputNodeTitle: 'New Subgraph',
|
||||
widgetNames: ['seed']
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
export async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph' = 'App'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await comfyExpect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await comfyExpect(appMode.saveAs.successMessage).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
export async function openWorkflowFromSidebar(
|
||||
comfyPage: ComfyPage,
|
||||
name: string
|
||||
) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.getActiveWorkflowPath())
|
||||
.toContain(name)
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
|
||||
19
browser_tests/helpers/clipboardSpy.ts
Normal file
19
browser_tests/helpers/clipboardSpy.ts
Normal 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
|
||||
)) ?? ''
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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
|
||||
|
||||
94
browser_tests/tests/appModeWidgetValues.spec.ts
Normal file
94
browser_tests/tests/appModeWidgetValues.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
382
browser_tests/tests/assetHelper.spec.ts
Normal file
382
browser_tests/tests/assetHelper.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
157
browser_tests/tests/builderReorder.spec.ts
Normal file
157
browser_tests/tests/builderReorder.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
const WIDGETS = ['seed', 'steps', 'cfg']
|
||||
|
||||
/** Save as app, close it by loading default, reopen from sidebar, enter app mode. */
|
||||
async function saveCloseAndReopenAsApp(
|
||||
comfyPage: ComfyPage,
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string
|
||||
) {
|
||||
await appMode.steps.goToPreview()
|
||||
await builderSaveAs(appMode, workflowName)
|
||||
await appMode.saveAs.closeButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await appMode.footer.exitBuilder()
|
||||
await openWorkflowFromSidebar(comfyPage, workflowName)
|
||||
await appMode.toggleAppMode()
|
||||
}
|
||||
|
||||
test.describe('Builder input reordering', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Drag first input to last position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
|
||||
test('Drag last input to first position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(2, 0)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'cfg',
|
||||
'seed',
|
||||
'steps'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'cfg',
|
||||
'seed',
|
||||
'steps'
|
||||
])
|
||||
})
|
||||
|
||||
test('Drag input to middle position', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await expect(appMode.select.inputItemTitles).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragInputItem(0, 1)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'seed',
|
||||
'cfg'
|
||||
])
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'seed',
|
||||
'cfg'
|
||||
])
|
||||
})
|
||||
|
||||
test('Reorder in preview step reflects in app mode after save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToPreview()
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText(WIDGETS)
|
||||
|
||||
await appMode.select.dragPreviewItem(0, 2)
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
const workflowName = `${Date.now()} reorder-preview`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
|
||||
test('Reorder inputs persists after save and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupBuilder(comfyPage, undefined, WIDGETS)
|
||||
|
||||
await appMode.steps.goToInputs()
|
||||
await appMode.select.dragInputItem(0, 2)
|
||||
await expect(appMode.select.inputItemTitles).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
|
||||
const workflowName = `${Date.now()} reorder-persist`
|
||||
await saveCloseAndReopenAsApp(comfyPage, appMode, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.select.previewWidgetLabels).toHaveText([
|
||||
'steps',
|
||||
'cfg',
|
||||
'seed'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,45 +2,14 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { AppModeHelper } from '../fixtures/helpers/AppModeHelper'
|
||||
import { setupBuilder } from '../helpers/builderTestUtils'
|
||||
import {
|
||||
builderSaveAs,
|
||||
openWorkflowFromSidebar,
|
||||
setupBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
/**
|
||||
* Open the save-as dialog, fill name + view type, click save,
|
||||
* and wait for the success dialog.
|
||||
*/
|
||||
async function builderSaveAs(
|
||||
appMode: AppModeHelper,
|
||||
workflowName: string,
|
||||
viewType: 'App' | 'Node graph'
|
||||
) {
|
||||
await appMode.footer.saveAsButton.click()
|
||||
await expect(appMode.saveAs.nameInput).toBeVisible({ timeout: 5000 })
|
||||
await appMode.saveAs.fillAndSave(workflowName, viewType)
|
||||
await expect(appMode.saveAs.successMessage).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a different workflow, then reopen the named one from the sidebar.
|
||||
* Caller must ensure the page is in graph mode (not builder or app mode)
|
||||
* before calling.
|
||||
*/
|
||||
async function openWorkflowFromSidebar(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(name).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const path = await comfyPage.workflow.getActiveWorkflowPath()
|
||||
expect(path).toContain(name)
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* After a first save, open save-as again from the chevron,
|
||||
* fill name + view type, and save.
|
||||
@@ -57,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
|
||||
@@ -203,10 +166,9 @@ test.describe('Builder save flow', { tag: ['@ui'] }, () => {
|
||||
|
||||
// Select I/O to enable the button
|
||||
await appMode.steps.goToInputs()
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await appMode.select.selectInputWidget(ksampler)
|
||||
await appMode.select.selectInputWidget('KSampler', 'seed')
|
||||
await appMode.steps.goToOutputs()
|
||||
await appMode.select.selectOutputNode()
|
||||
await appMode.select.selectOutputNode('Save Image')
|
||||
|
||||
// State 2: Enabled "Save as" (unsaved, has outputs)
|
||||
const enabledBox = await appMode.footer.saveAsButton.boundingBox()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
139
browser_tests/tests/errorDialog.spec.ts
Normal file
139
browser_tests/tests/errorDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
195
browser_tests/tests/errorOverlay.spec.ts
Normal file
195
browser_tests/tests/errorOverlay.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
40
browser_tests/tests/load3d/Load3DHelper.ts
Normal file
40
browser_tests/tests/load3d/Load3DHelper.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
97
browser_tests/tests/load3d/load3d.spec.ts
Normal file
97
browser_tests/tests/load3d/load3d.spec.ts
Normal 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 |
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
17
browser_tests/tests/propertiesPanel/ErrorsTabHelper.ts
Normal file
17
browser_tests/tests/propertiesPanel/ErrorsTabHelper.ts
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
49
browser_tests/tests/subgraph/subgraphZeroUuid.spec.ts
Normal file
49
browser_tests/tests/subgraph/subgraphZeroUuid.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Zero UUID workflow: subgraph undo rendering',
|
||||
{ tag: ['@workflow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
test.setTimeout(30000) // Extend timeout as we need to reload the page an additional time
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.page.reload() // Reload page as we need to enter in Vue mode
|
||||
await comfyPage.page.waitForFunction(() => !!window.app?.graph)
|
||||
})
|
||||
|
||||
test('Undo after subgraph enter/exit renders all nodes when workflow starts with zero UUID', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/basic-subgraph-zero-uuid'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const assertInSubgraph = async (inSubgraph: boolean) => {
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph())
|
||||
.toBe(inSubgraph)
|
||||
}
|
||||
|
||||
// Root graph has 1 subgraph node, rendered in the DOM
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await assertInSubgraph(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await assertInSubgraph(false)
|
||||
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// After undo, the subgraph node is still visible and rendered
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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
6
codecov.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
comment:
|
||||
layout: 'header, diff, flags, files'
|
||||
behavior: default
|
||||
require_changes: false
|
||||
require_base: false
|
||||
require_head: true
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 |
@@ -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)(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -170,6 +170,8 @@ defineExpose({ handleDragDrop })
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
: undefined
|
||||
"
|
||||
:data-testid="builderMode ? 'builder-widget-item' : 'app-mode-widget-item'"
|
||||
:data-widget-key="key"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
@@ -181,6 +183,7 @@ defineExpose({ handleDragDrop })
|
||||
>
|
||||
<span
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
data-testid="builder-widget-label"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
</span>
|
||||
|
||||
@@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { CurveInterpolation, CurvePoint } from './types'
|
||||
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
import { histogramToPath } from './curveUtils'
|
||||
|
||||
const {
|
||||
curveColor = 'white',
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { CurvePoint } from './types'
|
||||
import {
|
||||
createLinearInterpolator,
|
||||
createMonotoneInterpolator,
|
||||
curvesToLUT
|
||||
curvesToLUT,
|
||||
histogramToPath
|
||||
} from './curveUtils'
|
||||
|
||||
describe('createMonotoneInterpolator', () => {
|
||||
@@ -163,3 +164,37 @@ describe('curvesToLUT', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('histogramToPath', () => {
|
||||
it('returns empty string for empty histogram', () => {
|
||||
expect(histogramToPath(new Uint32Array(0))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when all bins are zero', () => {
|
||||
expect(histogramToPath(new Uint32Array(256))).toBe('')
|
||||
})
|
||||
|
||||
it('returns a closed SVG path for valid histogram', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const path = histogramToPath(histogram)
|
||||
expect(path).toMatch(/^M0,1/)
|
||||
expect(path).toMatch(/L1,1 Z$/)
|
||||
})
|
||||
|
||||
it('normalizes using 99.5th percentile to suppress outliers', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = 100
|
||||
histogram[255] = 100000
|
||||
const path = histogramToPath(histogram)
|
||||
// Most bins should map to y=0 (1 - 100/100 = 0) since
|
||||
// the 99.5th percentile is 100, not the outlier 100000
|
||||
const yValues = path
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
.map((s) => parseFloat(s.split(',')[1]))
|
||||
.filter((y) => !isNaN(y))
|
||||
const nearZero = yValues.filter((y) => Math.abs(y) < 0.01)
|
||||
expect(nearZero.length).toBeGreaterThan(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,6 +149,34 @@ export function createMonotoneInterpolator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a histogram (arbitrary number of bins) into an SVG path string.
|
||||
* Applies square-root scaling and normalizes using the 99.5th percentile
|
||||
* to avoid outlier spikes.
|
||||
*/
|
||||
export function histogramToPath(histogram: Uint32Array): string {
|
||||
const len = histogram.length
|
||||
if (len === 0) return ''
|
||||
|
||||
const sqrtValues = new Float32Array(len)
|
||||
for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i])
|
||||
|
||||
const sorted = Array.from(sqrtValues).sort((a, b) => a - b)
|
||||
const max = sorted[Math.floor((len - 1) * 0.995)]
|
||||
if (max === 0) return ''
|
||||
|
||||
const invMax = 1 / max
|
||||
const lastIdx = len - 1
|
||||
const parts: string[] = ['M0,1']
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = lastIdx === 0 ? 0.5 : i / lastIdx
|
||||
const y = 1 - Math.min(1, sqrtValues[i] * invMax)
|
||||
parts.push(`L${x},${y}`)
|
||||
}
|
||||
parts.push('L1,1 Z')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function curvesToLUT(
|
||||
points: CurvePoint[],
|
||||
interpolation: CurveInterpolation = 'monotone_cubic'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
|
||||
return mount(RangeEditor, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('RangeEditor', () => {
|
||||
it('renders with min and max handles', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('highlights selected range in plain mode', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const highlight = wrapper.find('[data-testid="range-highlight"]')
|
||||
expect(highlight.attributes('x')).toBe('0.2')
|
||||
expect(
|
||||
Number.parseFloat(highlight.attributes('width') ?? 'NaN')
|
||||
).toBeCloseTo(0.6, 6)
|
||||
})
|
||||
|
||||
it('dims area outside the range in histogram mode', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0.2, max: 0.8 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
const left = wrapper.find('[data-testid="range-dim-left"]')
|
||||
const right = wrapper.find('[data-testid="range-dim-right"]')
|
||||
expect(left.attributes('width')).toBe('0.2')
|
||||
expect(right.attributes('x')).toBe('0.8')
|
||||
})
|
||||
|
||||
it('hides midpoint handle by default', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 }
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows midpoint handle when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders gradient background when display is gradient', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'gradient',
|
||||
gradientStops: [
|
||||
{ offset: 0, color: [0, 0, 0] as const },
|
||||
{ offset: 1, color: [255, 255, 255] as const }
|
||||
]
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
|
||||
expect(wrapper.find('linearGradient').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders histogram path when display is histogram with data', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++)
|
||||
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
|
||||
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1 },
|
||||
display: 'histogram',
|
||||
histogram
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders inputs for min and max', () => {
|
||||
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect(inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders midpoint input when showMidpoint is true', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 0, max: 1, midpoint: 0.5 },
|
||||
showMidpoint: true
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
expect(inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('normalizes handle positions with custom value range', () => {
|
||||
const wrapper = mountEditor({
|
||||
modelValue: { min: 64, max: 192 },
|
||||
valueMin: 0,
|
||||
valueMax: 255
|
||||
})
|
||||
|
||||
const minHandle = wrapper.find('[data-testid="handle-min"]')
|
||||
const maxHandle = wrapper.find('[data-testid="handle-max"]')
|
||||
|
||||
expect(
|
||||
Number.parseFloat((minHandle.element as HTMLElement).style.left)
|
||||
).toBeCloseTo(25, 0)
|
||||
expect(
|
||||
Number.parseFloat((maxHandle.element as HTMLElement).style.left)
|
||||
).toBeCloseTo(75, 0)
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="relative select-none"
|
||||
@pointerdown.stop="onTrackPointerDown"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
:class="
|
||||
cn(
|
||||
'block w-full rounded-sm bg-node-component-surface',
|
||||
display === 'histogram' ? 'aspect-3/2' : 'h-8'
|
||||
)
|
||||
"
|
||||
>
|
||||
<defs v-if="display === 'gradient'">
|
||||
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop
|
||||
v-for="(stop, i) in computedStops"
|
||||
:key="i"
|
||||
:offset="stop.offset"
|
||||
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect
|
||||
v-if="display === 'gradient'"
|
||||
data-testid="gradient-bg"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"
|
||||
:fill="`url(#${gradientId})`"
|
||||
/>
|
||||
|
||||
<path
|
||||
v-if="display === 'histogram' && histogramPath"
|
||||
data-testid="histogram-path"
|
||||
:d="histogramPath"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.3"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="display === 'plain'"
|
||||
data-testid="range-highlight"
|
||||
:x="minNorm"
|
||||
y="0"
|
||||
:width="Math.max(0, maxNorm - minNorm)"
|
||||
height="1"
|
||||
fill="white"
|
||||
fill-opacity="0.15"
|
||||
/>
|
||||
<template v-if="display === 'histogram'">
|
||||
<rect
|
||||
v-if="minNorm > 0"
|
||||
data-testid="range-dim-left"
|
||||
x="0"
|
||||
y="0"
|
||||
:width="minNorm"
|
||||
height="1"
|
||||
fill="black"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<rect
|
||||
v-if="maxNorm < 1"
|
||||
data-testid="range-dim-right"
|
||||
:x="maxNorm"
|
||||
y="0"
|
||||
:width="1 - maxNorm"
|
||||
height="1"
|
||||
fill="black"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<template v-if="!disabled">
|
||||
<div
|
||||
data-testid="handle-min"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('min', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="#333"
|
||||
stroke="#aaa"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showMidpoint && modelValue.midpoint !== undefined"
|
||||
data-testid="handle-midpoint"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('midpoint', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="#888"
|
||||
stroke="#ccc"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="handle-max"
|
||||
class="absolute -translate-x-1/2 cursor-grab"
|
||||
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
|
||||
@pointerdown.stop="startDrag('max', $event)"
|
||||
>
|
||||
<svg width="12" height="10" viewBox="0 0 12 10">
|
||||
<polygon
|
||||
points="6,0 0,10 12,10"
|
||||
fill="white"
|
||||
stroke="#555"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!disabled"
|
||||
class="mt-3 flex items-center justify-between"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<ScrubableNumberInput
|
||||
v-model="minValue"
|
||||
:display-value="formatValue(minValue)"
|
||||
:min="valueMin"
|
||||
:max="valueMax"
|
||||
:step="step"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-if="showMidpoint && modelValue.midpoint !== undefined"
|
||||
v-model="midpointValue"
|
||||
:display-value="midpointValue.toFixed(2)"
|
||||
:min="midpointScale === 'gamma' ? 0.01 : 0"
|
||||
:max="midpointScale === 'gamma' ? 9.99 : 1"
|
||||
:step="0.01"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="maxValue"
|
||||
:display-value="formatValue(maxValue)"
|
||||
:min="valueMin"
|
||||
:max="valueMax"
|
||||
:step="step"
|
||||
hide-buttons
|
||||
class="w-16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, useId, useTemplateRef } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
import { useRangeEditor } from '@/composables/useRangeEditor'
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import {
|
||||
clamp,
|
||||
gammaToPosition,
|
||||
normalize,
|
||||
positionToGamma
|
||||
} from './rangeUtils'
|
||||
|
||||
const {
|
||||
display = 'plain',
|
||||
gradientStops,
|
||||
showMidpoint = false,
|
||||
midpointScale = 'linear',
|
||||
histogram,
|
||||
disabled = false,
|
||||
valueMin = 0,
|
||||
valueMax = 1
|
||||
} = defineProps<{
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradientStops?: ColorStop[]
|
||||
showMidpoint?: boolean
|
||||
midpointScale?: 'linear' | 'gamma'
|
||||
histogram?: Uint32Array | null
|
||||
disabled?: boolean
|
||||
valueMin?: number
|
||||
valueMax?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({ required: true })
|
||||
|
||||
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
|
||||
const gradientId = useId()
|
||||
|
||||
const { handleTrackPointerDown, startDrag } = useRangeEditor({
|
||||
trackRef,
|
||||
modelValue,
|
||||
valueMin: toRef(() => valueMin),
|
||||
valueMax: toRef(() => valueMax)
|
||||
})
|
||||
|
||||
function onTrackPointerDown(e: PointerEvent) {
|
||||
if (!disabled) handleTrackPointerDown(e)
|
||||
}
|
||||
|
||||
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
|
||||
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
|
||||
|
||||
function formatValue(v: number): string {
|
||||
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
|
||||
}
|
||||
|
||||
const minNorm = computed(() =>
|
||||
normalize(modelValue.value.min, valueMin, valueMax)
|
||||
)
|
||||
const maxNorm = computed(() =>
|
||||
normalize(modelValue.value.max, valueMin, valueMax)
|
||||
)
|
||||
|
||||
const computedStops = computed(
|
||||
() =>
|
||||
gradientStops ?? [
|
||||
{ offset: 0, color: [0, 0, 0] as const },
|
||||
{ offset: 1, color: [255, 255, 255] as const }
|
||||
]
|
||||
)
|
||||
|
||||
const midpointPercent = computed(() => {
|
||||
const { min, max, midpoint } = modelValue.value
|
||||
if (midpoint === undefined) return 0
|
||||
const midAbs = min + midpoint * (max - min)
|
||||
return normalize(midAbs, valueMin, valueMax) * 100
|
||||
})
|
||||
|
||||
const minValue = computed({
|
||||
get: () => modelValue.value.min,
|
||||
set: (min) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const maxValue = computed({
|
||||
get: () => modelValue.value.max,
|
||||
set: (max) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const midpointValue = computed({
|
||||
get: () => {
|
||||
const pos = modelValue.value.midpoint ?? 0.5
|
||||
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
|
||||
},
|
||||
set: (val) => {
|
||||
const position =
|
||||
midpointScale === 'gamma'
|
||||
? clamp(gammaToPosition(val), 0, 1)
|
||||
: clamp(val, 0, 1)
|
||||
modelValue.value = { ...modelValue.value, midpoint: position }
|
||||
}
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
histogram ? histogramToPath(histogram) : ''
|
||||
)
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<RangeEditor
|
||||
:model-value="effectiveValue.value"
|
||||
:display="widget?.options?.display"
|
||||
:gradient-stops="widget?.options?.gradient_stops"
|
||||
:show-midpoint="widget?.options?.show_midpoint"
|
||||
:midpoint-scale="widget?.options?.midpoint_scale"
|
||||
:histogram="histogram"
|
||||
:disabled="isDisabled"
|
||||
:value-min="widget?.options?.value_min"
|
||||
:value-max="widget?.options?.value_max"
|
||||
@update:model-value="onValueChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import {
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from '@/composables/useUpstreamValue'
|
||||
import type {
|
||||
IWidgetRangeOptions,
|
||||
RangeValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import RangeEditor from './RangeEditor.vue'
|
||||
import { isRangeValue } from './rangeUtils'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<RangeValue>({
|
||||
default: () => ({ min: 0, max: 1 })
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => !!widget.options?.disabled)
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const histogram = computed(() => {
|
||||
const locatorId = widget.nodeLocatorId
|
||||
if (!locatorId) return null
|
||||
const output = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const key = `histogram_${widget.name}`
|
||||
const data = (output as Record<string, unknown>)?.[key]
|
||||
if (!Array.isArray(data) || data.length === 0) return null
|
||||
return new Uint32Array(data)
|
||||
})
|
||||
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => widget.linkedUpstream,
|
||||
singleValueExtractor(isRangeValue)
|
||||
)
|
||||
|
||||
watch(upstreamValue, (upstream) => {
|
||||
if (isDisabled.value && upstream) {
|
||||
modelValue.value = upstream
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveValue = computed(() =>
|
||||
isDisabled.value && upstreamValue.value
|
||||
? { value: upstreamValue.value }
|
||||
: { value: modelValue.value }
|
||||
)
|
||||
|
||||
function onValueChange(value: RangeValue) {
|
||||
modelValue.value = value
|
||||
}
|
||||
</script>
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
constrainRange,
|
||||
denormalize,
|
||||
formatMidpointLabel,
|
||||
gammaToPosition,
|
||||
isRangeValue,
|
||||
normalize,
|
||||
positionToGamma
|
||||
} from './rangeUtils'
|
||||
|
||||
describe('normalize', () => {
|
||||
it('normalizes value to 0-1', () => {
|
||||
expect(normalize(128, 0, 256)).toBe(0.5)
|
||||
expect(normalize(0, 0, 255)).toBe(0)
|
||||
expect(normalize(255, 0, 255)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 when min equals max', () => {
|
||||
expect(normalize(5, 5, 5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('denormalize', () => {
|
||||
it('converts normalized value back to range', () => {
|
||||
expect(denormalize(0.5, 0, 256)).toBe(128)
|
||||
expect(denormalize(0, 0, 255)).toBe(0)
|
||||
expect(denormalize(1, 0, 255)).toBe(255)
|
||||
})
|
||||
|
||||
it('round-trips with normalize', () => {
|
||||
expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('positionToGamma', () => {
|
||||
it('converts 0.5 to gamma 1.0', () => {
|
||||
expect(positionToGamma(0.5)).toBeCloseTo(1.0)
|
||||
})
|
||||
|
||||
it('converts 0.25 to gamma 2.0', () => {
|
||||
expect(positionToGamma(0.25)).toBeCloseTo(2.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gammaToPosition', () => {
|
||||
it('converts gamma 1.0 to position 0.5', () => {
|
||||
expect(gammaToPosition(1.0)).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('converts gamma 2.0 to position 0.25', () => {
|
||||
expect(gammaToPosition(2.0)).toBeCloseTo(0.25)
|
||||
})
|
||||
|
||||
it('round-trips with positionToGamma', () => {
|
||||
for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
||||
expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMidpointLabel', () => {
|
||||
it('formats linear scale as decimal', () => {
|
||||
expect(formatMidpointLabel(0.5, 'linear')).toBe('0.50')
|
||||
})
|
||||
|
||||
it('formats gamma scale as gamma value', () => {
|
||||
expect(formatMidpointLabel(0.5, 'gamma')).toBe('1.00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constrainRange', () => {
|
||||
it('passes through valid range unchanged', () => {
|
||||
const result = constrainRange({ min: 0.2, max: 0.8 })
|
||||
expect(result).toEqual({ min: 0.2, max: 0.8, midpoint: undefined })
|
||||
})
|
||||
|
||||
it('clamps values to default [0, 1]', () => {
|
||||
const result = constrainRange({ min: -0.5, max: 1.5 })
|
||||
expect(result.min).toBe(0)
|
||||
expect(result.max).toBe(1)
|
||||
})
|
||||
|
||||
it('clamps values to custom range', () => {
|
||||
const result = constrainRange({ min: -10, max: 300 }, 0, 255)
|
||||
expect(result.min).toBe(0)
|
||||
expect(result.max).toBe(255)
|
||||
})
|
||||
|
||||
it('enforces min <= max', () => {
|
||||
const result = constrainRange({ min: 0.8, max: 0.3 })
|
||||
expect(result.min).toBe(0.8)
|
||||
expect(result.max).toBe(0.8)
|
||||
})
|
||||
|
||||
it('preserves midpoint when present', () => {
|
||||
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 0.5 })
|
||||
expect(result.midpoint).toBe(0.5)
|
||||
})
|
||||
|
||||
it('clamps midpoint to [0, 1]', () => {
|
||||
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 1.5 })
|
||||
expect(result.midpoint).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRangeValue', () => {
|
||||
it('returns true for valid range', () => {
|
||||
expect(isRangeValue({ min: 0, max: 1 })).toBe(true)
|
||||
expect(isRangeValue({ min: 0, max: 1, midpoint: 0.5 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-objects', () => {
|
||||
expect(isRangeValue(null)).toBe(false)
|
||||
expect(isRangeValue(42)).toBe(false)
|
||||
expect(isRangeValue('foo')).toBe(false)
|
||||
expect(isRangeValue([0, 1])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for objects missing min or max', () => {
|
||||
expect(isRangeValue({ min: 0 })).toBe(false)
|
||||
expect(isRangeValue({ max: 1 })).toBe(false)
|
||||
expect(isRangeValue({ min: 'a', max: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export { clamp }
|
||||
|
||||
export function normalize(value: number, min: number, max: number): number {
|
||||
return max === min ? 0 : (value - min) / (max - min)
|
||||
}
|
||||
|
||||
export function denormalize(
|
||||
normalized: number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return min + normalized * (max - min)
|
||||
}
|
||||
|
||||
export function positionToGamma(position: number): number {
|
||||
const clamped = clamp(position, 0.001, 0.999)
|
||||
return -Math.log2(clamped)
|
||||
}
|
||||
|
||||
export function gammaToPosition(gamma: number): number {
|
||||
return Math.pow(2, -gamma)
|
||||
}
|
||||
|
||||
export function formatMidpointLabel(
|
||||
position: number,
|
||||
scale: 'linear' | 'gamma'
|
||||
): string {
|
||||
if (scale === 'gamma') {
|
||||
return positionToGamma(position).toFixed(2)
|
||||
}
|
||||
return position.toFixed(2)
|
||||
}
|
||||
|
||||
export function constrainRange(
|
||||
value: RangeValue,
|
||||
valueMin: number = 0,
|
||||
valueMax: number = 1
|
||||
): RangeValue {
|
||||
const min = clamp(value.min, valueMin, valueMax)
|
||||
const max = clamp(Math.max(min, value.max), valueMin, valueMax)
|
||||
const midpoint =
|
||||
value.midpoint !== undefined ? clamp(value.midpoint, 0, 1) : undefined
|
||||
return { min, max, midpoint }
|
||||
}
|
||||
|
||||
export function isRangeValue(value: unknown): value is RangeValue {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
return false
|
||||
const v = value as Record<string, unknown>
|
||||
const hasFiniteBounds = Number.isFinite(v.min) && Number.isFinite(v.max)
|
||||
const hasValidMidpoint =
|
||||
v.midpoint === undefined || Number.isFinite(v.midpoint)
|
||||
return hasFiniteBounds && hasValidMidpoint
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import { denormalize, normalize } from '@/components/range/rangeUtils'
|
||||
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type HandleType = 'min' | 'max' | 'midpoint'
|
||||
|
||||
interface UseRangeEditorOptions {
|
||||
trackRef: Ref<HTMLElement | null>
|
||||
modelValue: Ref<RangeValue>
|
||||
valueMin: Ref<number>
|
||||
valueMax: Ref<number>
|
||||
}
|
||||
|
||||
export function useRangeEditor({
|
||||
trackRef,
|
||||
modelValue,
|
||||
valueMin,
|
||||
valueMax
|
||||
}: UseRangeEditorOptions) {
|
||||
const activeHandle = ref<HandleType | null>(null)
|
||||
let cleanupDrag: (() => void) | null = null
|
||||
|
||||
function pointerToValue(e: PointerEvent): number {
|
||||
const el = trackRef.value
|
||||
if (!el) return valueMin.value
|
||||
const rect = el.getBoundingClientRect()
|
||||
const normalized = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width)
|
||||
)
|
||||
return denormalize(normalized, valueMin.value, valueMax.value)
|
||||
}
|
||||
|
||||
function nearestHandle(value: number): HandleType {
|
||||
const { min, max, midpoint } = modelValue.value
|
||||
const dMin = Math.abs(value - min)
|
||||
const dMax = Math.abs(value - max)
|
||||
let best: HandleType = dMin <= dMax ? 'min' : 'max'
|
||||
const bestDist = Math.min(dMin, dMax)
|
||||
if (midpoint !== undefined) {
|
||||
const midAbs = min + midpoint * (max - min)
|
||||
if (Math.abs(value - midAbs) < bestDist) {
|
||||
best = 'midpoint'
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function updateValue(handle: HandleType, value: number) {
|
||||
const current = modelValue.value
|
||||
const clamped = clamp(value, valueMin.value, valueMax.value)
|
||||
|
||||
if (handle === 'min') {
|
||||
modelValue.value = { ...current, min: Math.min(clamped, current.max) }
|
||||
} else if (handle === 'max') {
|
||||
modelValue.value = { ...current, max: Math.max(clamped, current.min) }
|
||||
} else {
|
||||
const range = current.max - current.min
|
||||
const midNorm =
|
||||
range > 0 ? normalize(clamped, current.min, current.max) : 0
|
||||
const midpoint = Math.max(0, Math.min(1, midNorm))
|
||||
modelValue.value = { ...current, midpoint }
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrackPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
startDrag(nearestHandle(pointerToValue(e)), e)
|
||||
}
|
||||
|
||||
function startDrag(handle: HandleType, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
cleanupDrag?.()
|
||||
|
||||
activeHandle.value = handle
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!activeHandle.value) return
|
||||
updateValue(activeHandle.value, pointerToValue(ev))
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (!activeHandle.value) return
|
||||
activeHandle.value = null
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', endDrag)
|
||||
el.removeEventListener('lostpointercapture', endDrag)
|
||||
cleanupDrag = null
|
||||
}
|
||||
|
||||
cleanupDrag = endDrag
|
||||
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', endDrag)
|
||||
el.addEventListener('lostpointercapture', endDrag)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDrag?.()
|
||||
})
|
||||
|
||||
return {
|
||||
activeHandle,
|
||||
handleTrackPointerDown,
|
||||
startDrag
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
@@ -1005,3 +1006,25 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zero UUID handling in configure', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
it('rejects zeroUuid for root graphs and assigns a new ID', () => {
|
||||
const graph = new LGraph()
|
||||
const data = graph.serialize()
|
||||
data.id = zeroUuid
|
||||
graph.configure(data)
|
||||
expect(graph.id).not.toBe(zeroUuid)
|
||||
})
|
||||
|
||||
it('preserves zeroUuid for subgraphs', () => {
|
||||
const graph = new LGraph()
|
||||
const subgraphData = { ...createTestSubgraphData(), id: zeroUuid }
|
||||
const subgraph = graph.createSubgraph(subgraphData)
|
||||
subgraph.configure(subgraphData)
|
||||
expect(subgraph.id).toBe(zeroUuid)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2480,8 +2480,8 @@ export class LGraph
|
||||
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
|
||||
const { id, extra } = data
|
||||
|
||||
// Create a new graph ID if none is provided
|
||||
if (id) {
|
||||
// Create a new graph ID if none is provided or the zero UUID is used on the root graph
|
||||
if (id && !(this.isRootGraph && id === zeroUuid)) {
|
||||
this.id = id
|
||||
} else if (this.id === zeroUuid) {
|
||||
this.id = createUuidv4()
|
||||
|
||||
@@ -139,7 +139,6 @@ export type IWidget =
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -342,31 +341,6 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
midpoint?: number
|
||||
}
|
||||
|
||||
export interface IWidgetRangeOptions extends IWidgetOptions {
|
||||
display?: 'plain' | 'gradient' | 'histogram'
|
||||
gradient_stops?: ColorStop[]
|
||||
show_midpoint?: boolean
|
||||
midpoint_scale?: 'linear' | 'gamma'
|
||||
value_min?: number
|
||||
value_max?: number
|
||||
histogram?: Uint32Array | null
|
||||
}
|
||||
|
||||
export interface IRangeWidget extends IBaseWidget<
|
||||
RangeValue,
|
||||
'range',
|
||||
IWidgetRangeOptions
|
||||
> {
|
||||
type: 'range'
|
||||
value: RangeValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { IRangeWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class RangeWidget
|
||||
extends BaseWidget<IRangeWidget>
|
||||
implements IRangeWidget
|
||||
{
|
||||
override type = 'range' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Range')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { RangeWidget } from './RangeWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
import { KnobWidget } from './KnobWidget'
|
||||
import { LegacyWidget } from './LegacyWidget'
|
||||
@@ -61,7 +60,6 @@ export type WidgetTypeMap = {
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -142,8 +140,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
case 'painter':
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
case 'range':
|
||||
return toClass(RangeWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
157
src/platform/assets/composables/useUploadModelWizard.test.ts
Normal file
157
src/platform/assets/composables/useUploadModelWizard.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user