mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Merge branch 'main' into test/painter-widget-E2E-test-plan
This commit is contained in:
16
.github/workflows/release-version-bump.yaml
vendored
16
.github/workflows/release-version-bump.yaml
vendored
@@ -142,10 +142,22 @@ jobs:
|
||||
fi
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Ensure packageManager field exists
|
||||
run: |
|
||||
if ! grep -q '"packageManager"' package.json; then
|
||||
# Old branches (e.g. core/1.42) predate the packageManager field.
|
||||
# Inject it so pnpm/action-setup can resolve the version.
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
|
||||
pkg.packageManager = 'pnpm@10.33.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
echo "Injected packageManager into package.json for legacy branch"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -179,6 +179,12 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
|
||||
## Design Standards
|
||||
|
||||
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
|
||||
|
||||
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/\*.md for detailed patterns.
|
||||
@@ -226,6 +232,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- shadcn/vue: <https://www.shadcn-vue.com/>
|
||||
- Reka UI: <https://reka-ui.com/>
|
||||
- PrimeVue: <https://primevue.org>
|
||||
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
|
||||
@@ -62,6 +62,37 @@ python main.py --port 8188 --cpu
|
||||
- Run `pnpm dev:electron` to start the dev server with electron API mocked
|
||||
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
|
||||
|
||||
#### Testing with Cloud & Staging Environments
|
||||
|
||||
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
|
||||
|
||||
**Option 1: Frontend — `pnpm dev:cloud`**
|
||||
|
||||
The simplest approach. This proxies all API requests to the test cloud environment:
|
||||
|
||||
```bash
|
||||
pnpm dev:cloud
|
||||
```
|
||||
|
||||
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
|
||||
```
|
||||
|
||||
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
|
||||
|
||||
**Option 2: Backend — `--comfy-api-base`**
|
||||
|
||||
Alternatively, launch the ComfyUI backend pointed at the staging API:
|
||||
|
||||
```bash
|
||||
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
|
||||
```
|
||||
|
||||
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
|
||||
|
||||
#### Access dev server on touch devices
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
@@ -34,6 +34,7 @@ import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
@@ -181,6 +182,7 @@ export class ComfyPage {
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -232,6 +234,7 @@ export class ComfyPage {
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.assetApi = createAssetHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
@@ -444,6 +447,10 @@ export const comfyPageFixture = base.extend<{
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
if (testInfo.tags.includes('@cloud')) {
|
||||
await comfyPage.cloudAuth.mockAuth()
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const needsPerf =
|
||||
|
||||
171
browser_tests/fixtures/helpers/CloudAuthHelper.ts
Normal file
171
browser_tests/fixtures/helpers/CloudAuthHelper.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Mocks Firebase authentication for cloud E2E tests.
|
||||
*
|
||||
* The cloud build's router guard waits for Firebase `onAuthStateChanged`
|
||||
* to fire, then checks `getAuthHeader()`. In CI no Firebase project is
|
||||
* configured, so the user is never authenticated and the app redirects
|
||||
* to `/cloud/login`.
|
||||
*
|
||||
* This helper seeds Firebase's IndexedDB persistence layer with a mock
|
||||
* user and intercepts the Firebase REST APIs (securetoken, identitytoolkit)
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
*/
|
||||
async mockAuth(): Promise<void> {
|
||||
await this.seedFirebaseIndexedDB()
|
||||
await this.mockFirebaseEndpoints()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a lightweight same-origin page to seed Firebase's
|
||||
* IndexedDB persistence with a mock user. This ensures the data
|
||||
* is written before the app loads and Firebase reads it.
|
||||
*
|
||||
* Firebase auth uses `browserLocalPersistence` which stores data in
|
||||
* IndexedDB database `firebaseLocalStorageDb`, object store
|
||||
* `firebaseLocalStorage`, keyed by `firebase:authUser:<apiKey>:<appName>`.
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
uid: 'test-user-e2e',
|
||||
email: 'e2e@test.comfy.org',
|
||||
displayName: 'E2E Test User',
|
||||
emailVerified: true,
|
||||
isAnonymous: false,
|
||||
providerData: [
|
||||
{
|
||||
providerId: 'google.com',
|
||||
uid: 'test-user-e2e',
|
||||
displayName: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
phoneNumber: null,
|
||||
photoURL: null
|
||||
}
|
||||
],
|
||||
stsTokenManager: {
|
||||
refreshToken: 'mock-refresh-token',
|
||||
accessToken: 'mock-firebase-id-token',
|
||||
expirationTime: Date.now() + 60 * 60 * 1000
|
||||
},
|
||||
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
|
||||
appName: '[DEFAULT]'
|
||||
}
|
||||
|
||||
const DB_NAME = 'firebaseLocalStorageDb'
|
||||
const STORE_NAME = 'firebaseLocalStorage'
|
||||
const KEY = `firebase:authUser:${MOCK_USER_DATA.apiKey}:${MOCK_USER_DATA.appName}`
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.close()
|
||||
const upgradeReq = indexedDB.open(DB_NAME, db.version + 1)
|
||||
upgradeReq.onerror = () => reject(upgradeReq.error)
|
||||
upgradeReq.onupgradeneeded = () => {
|
||||
const upgradedDb = upgradeReq.result
|
||||
if (!upgradedDb.objectStoreNames.contains(STORE_NAME)) {
|
||||
upgradedDb.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
upgradeReq.onsuccess = () => {
|
||||
const upgradedDb = upgradeReq.result
|
||||
const tx = upgradedDb.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(
|
||||
{ fpiVersion: '1', value: MOCK_USER_DATA },
|
||||
KEY
|
||||
)
|
||||
tx.oncomplete = () => {
|
||||
upgradedDb.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
tx.objectStore(STORE_NAME).put(
|
||||
{ fpiVersion: '1', value: MOCK_USER_DATA },
|
||||
KEY
|
||||
)
|
||||
tx.oncomplete = () => {
|
||||
db.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept Firebase Auth REST API endpoints so the SDK can
|
||||
* "refresh" the mock user's token without real credentials.
|
||||
*/
|
||||
private async mockFirebaseEndpoints(): Promise<void> {
|
||||
await this.page.route('**/securetoken.googleapis.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: 'mock-access-token',
|
||||
expires_in: '3600',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'mock-refresh-token',
|
||||
id_token: 'mock-firebase-id-token',
|
||||
user_id: 'test-user-e2e',
|
||||
project_id: 'dreamboothy-dev'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await this.page.route('**/identitytoolkit.googleapis.com/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
kind: 'identitytoolkit#GetAccountInfoResponse',
|
||||
users: [
|
||||
{
|
||||
localId: 'test-user-e2e',
|
||||
email: 'e2e@test.comfy.org',
|
||||
displayName: 'E2E Test User',
|
||||
emailVerified: true,
|
||||
validSince: '0',
|
||||
lastLoginAt: String(Date.now()),
|
||||
createdAt: String(Date.now()),
|
||||
lastRefreshAt: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await this.page.route('**/__/auth/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html><body></body></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,8 @@ export const TestIds = {
|
||||
missingMediaStatusCard: 'missing-media-status-card',
|
||||
missingMediaConfirmButton: 'missing-media-confirm-button',
|
||||
missingMediaCancelButton: 'missing-media-cancel-button',
|
||||
missingMediaLocateButton: 'missing-media-locate-button'
|
||||
missingMediaLocateButton: 'missing-media-locate-button',
|
||||
publishTabPanel: 'publish-tab-panel'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
|
||||
export interface PromotedWidgetSnapshot {
|
||||
proxyWidgets: PromotedWidgetEntry[]
|
||||
widgetNames: string[]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetEntry(
|
||||
entry: unknown
|
||||
): entry is PromotedWidgetEntry {
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
@@ -18,9 +11,7 @@ export function isPromotedWidgetEntry(
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizePromotedWidgets(
|
||||
value: unknown
|
||||
): PromotedWidgetEntry[] {
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
}
|
||||
@@ -37,28 +28,6 @@ export async function getPromotedWidgets(
|
||||
return normalizePromotedWidgets(raw)
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetSnapshot> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
return {
|
||||
proxyWidgets: node?.properties?.proxyWidgets ?? [],
|
||||
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
|
||||
}
|
||||
}, nodeId)
|
||||
|
||||
return {
|
||||
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
|
||||
widgetNames: Array.isArray(raw.widgetNames)
|
||||
? raw.widgetNames.filter(
|
||||
(name): name is string => typeof name === 'string'
|
||||
)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
@@ -75,7 +44,7 @@ export async function getPromotedWidgetCount(
|
||||
return promotedWidgets.length
|
||||
}
|
||||
|
||||
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
|
||||
return entry[1].startsWith('$$')
|
||||
}
|
||||
|
||||
@@ -87,14 +56,6 @@ export async function getPseudoPreviewWidgets(
|
||||
return widgets.filter(isPseudoPreviewEntry)
|
||||
}
|
||||
|
||||
export async function getNonPreviewPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const widgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetCountByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
|
||||
352
browser_tests/tests/dialogs/shareWorkflowDialog.spec.ts
Normal file
352
browser_tests/tests/dialogs/shareWorkflowDialog.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { AssetInfo } from '../../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
interface PublishRecord {
|
||||
workflow_id: string
|
||||
share_id: string | null
|
||||
listed: boolean
|
||||
publish_time: string | null
|
||||
}
|
||||
|
||||
const PUBLISHED_RECORD: PublishRecord = {
|
||||
workflow_id: 'wf-1',
|
||||
share_id: 'share-abc',
|
||||
listed: false,
|
||||
publish_time: new Date(Date.now() + 60_000).toISOString()
|
||||
}
|
||||
|
||||
const PRIVATE_ASSET: AssetInfo = {
|
||||
id: 'asset-1',
|
||||
name: 'photo.png',
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
/**
|
||||
* Enable the workflow_sharing_enabled server feature flag at runtime.
|
||||
* FeatureFlagHelper.mockServerFeatures() intercepts `/api/features` but the
|
||||
* flags are already loaded by the time tests run, so direct mutation of the
|
||||
* reactive ref is the only reliable approach for server-side flags.
|
||||
*/
|
||||
async function enableWorkflowSharing(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
workflow_sharing_enabled: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mockPublishStatus(
|
||||
page: Page,
|
||||
record: PublishRecord | null
|
||||
): Promise<void> {
|
||||
await page.route('**/api/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
if (!record || !record.share_id) {
|
||||
await route.fulfill({ status: 404, body: 'Not found' })
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(record)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mockPublishWorkflow(
|
||||
page: Page,
|
||||
result: PublishRecord
|
||||
): Promise<void> {
|
||||
await page.route('**/api/userdata/*/publish', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(result)
|
||||
})
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function mockShareableAssets(
|
||||
page: Page,
|
||||
assets: AssetInfo[] = []
|
||||
): Promise<void> {
|
||||
await page.route('**/api/assets/from-workflow', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ assets })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the active workflow via the topbar Save menu action.
|
||||
* Mocks the userdata POST endpoint to avoid real server calls in tests.
|
||||
*/
|
||||
async function saveAndWait(
|
||||
comfyPage: {
|
||||
page: Page
|
||||
menu: { topbar: { saveWorkflow: (name: string) => Promise<void> } }
|
||||
},
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
const filename =
|
||||
workflowName + (workflowName.endsWith('.json') ? '' : '.json')
|
||||
await comfyPage.page.route(
|
||||
/\/api\/userdata\/workflows(%2F|\/).*$/,
|
||||
async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
path: `workflows/${filename}`,
|
||||
size: 1024,
|
||||
modified: Date.now()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
}
|
||||
|
||||
async function openShareDialog(page: Page): Promise<void> {
|
||||
await enableWorkflowSharing(page)
|
||||
await dismissOverlays(page)
|
||||
const shareButton = page.getByRole('button', { name: 'Share workflow' })
|
||||
await shareButton.click()
|
||||
}
|
||||
|
||||
function getShareDialog(page: Page) {
|
||||
return page.getByRole('dialog')
|
||||
}
|
||||
|
||||
test.describe('Share Workflow Dialog', { tag: '@cloud' }, () => {
|
||||
test('should show unsaved state for a new workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /save workflow/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show ready state with create link button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-ready'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /create a link/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show shared state with copy URL after publishing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-shared'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, PUBLISHED_RECORD)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: /share.*url/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show stale state with update link button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-stale'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
const staleRecord: PublishRecord = {
|
||||
...PUBLISHED_RECORD,
|
||||
publish_time: '2020-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
await mockPublishStatus(page, staleRecord)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /update\s+link/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close dialog when close button is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('should create link and transition to shared state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-create'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await mockPublishWorkflow(page, PUBLISHED_RECORD)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
const createButton = dialog.getByRole('button', { name: /create a link/i })
|
||||
await expect(createButton).toBeVisible()
|
||||
await createButton.click()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('textbox', { name: /share.*url/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show tab buttons when comfyHubUploadEnabled is true', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
comfyhub_upload_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole('tab', { name: /share/i })).toBeVisible()
|
||||
await expect(dialog.getByRole('tab', { name: /publish/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between share link and publish tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await page.evaluate(() => {
|
||||
const api = window.app!.api
|
||||
api.serverFeatureFlags.value = {
|
||||
...api.serverFeatureFlags.value,
|
||||
comfyhub_upload_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page)
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('tab', { name: /publish/i }).click()
|
||||
|
||||
const publishPanel = dialog.getByTestId(TestIds.dialogs.publishTabPanel)
|
||||
await expect(publishPanel).toBeVisible()
|
||||
|
||||
await dialog.getByRole('tab', { name: /share/i }).click()
|
||||
await expect(publishPanel).toBeHidden()
|
||||
})
|
||||
|
||||
test('should require acknowledgment before publishing private assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const workflowName = 'share-test-ack'
|
||||
|
||||
await saveAndWait(comfyPage, workflowName)
|
||||
|
||||
await mockPublishStatus(page, null)
|
||||
await mockShareableAssets(page, [PRIVATE_ASSET])
|
||||
await openShareDialog(page)
|
||||
|
||||
const dialog = getShareDialog(page)
|
||||
const createButton = dialog.getByRole('button', { name: /create a link/i })
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
await dialog.getByRole('checkbox').check()
|
||||
await expect(createButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,34 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
|
||||
test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
async function duplicateSubgraphNodeViaAltDrag(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
@@ -37,18 +52,14 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
(input) => input.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -62,14 +73,15 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(1)
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
|
||||
.length
|
||||
)
|
||||
.toBe(1)
|
||||
await expect.poll(() => comfyPage.subgraph.getNodeCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
@@ -82,69 +94,47 @@ test.describe('Subgraph CRUD', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
const finalNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialNodeCount - 1)
|
||||
await expect.poll(() => deletedNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Subgraph copy and paste', () => {
|
||||
test('Can copy subgraph node by dragging + alt', async ({
|
||||
test.describe('Subgraph Copy', () => {
|
||||
test('Can duplicate a subgraph node by alt-dragging', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await duplicateSubgraphNodeViaAltDrag(comfyPage)
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect a second subgraph node to be created (2 total)
|
||||
expect(subgraphNodes.length).toBe(2)
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
|
||||
.length
|
||||
)
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Copying subgraph node by dragging + alt creates a new subgraph node with unique type', async ({
|
||||
test('Alt-dragging a subgraph node creates a new subgraph type', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await duplicateSubgraphNodeViaAltDrag(comfyPage)
|
||||
|
||||
// Get position of subgraph node
|
||||
const subgraphPos = await subgraphNode.getPosition()
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE))
|
||||
.length
|
||||
)
|
||||
.toBe(2)
|
||||
|
||||
// Alt + Click on the subgraph node
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 16, subgraphPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag slightly to trigger the copy
|
||||
await comfyPage.page.mouse.move(subgraphPos.x + 64, subgraphPos.y + 64)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
// Find all subgraph nodes and expect all unique IDs
|
||||
const subgraphNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
|
||||
// Expect the second subgraph node to have a unique type
|
||||
const nodeType1 = await subgraphNodes[0].getType()
|
||||
const nodeType2 = await subgraphNodes[1].getType()
|
||||
expect(nodeType1).not.toBe(nodeType2)
|
||||
|
||||
@@ -2,209 +2,91 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPseudoPreviewWidgets,
|
||||
getNonPreviewPromotedWidgets
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
test.describe(
|
||||
'Subgraph Lifecycle Edge Behaviors',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const hostNode = window.app!.canvas.graph!.getNodeById('11')
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return {
|
||||
proxyWidgetCount: Array.isArray(proxyWidgets)
|
||||
? proxyWidgets.length
|
||||
: 0,
|
||||
firstWidgetType: hostNode?.widgets?.[0]?.type
|
||||
}
|
||||
})
|
||||
})
|
||||
.toEqual({
|
||||
proxyWidgetCount: 0,
|
||||
firstWidgetType: undefined
|
||||
})
|
||||
})
|
||||
|
||||
test('Promoted widget disappears from DOM after interior node deletion', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
test('Deleting the promoted source removes the exterior DOM widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
|
||||
).toBe(true)
|
||||
})
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
test('Non-preview widgets coexist with pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
|
||||
comfyPage,
|
||||
'5'
|
||||
)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
expect(pseudoWidgets.length).toBeGreaterThan(0)
|
||||
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
|
||||
).toBe(true)
|
||||
})
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
return graph.nodes.filter((n) => n.isSubgraphNode()).length
|
||||
})
|
||||
expect(subgraphNodeCount).toBe(0)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
expect(await subgraphNode.exists()).toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
|
||||
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
|
||||
expect(firstNodeBefore.length).toBeGreaterThan(0)
|
||||
expect(secondNodeBefore.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('7')
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstNodeExists = await comfyPage.page.evaluate(() => {
|
||||
return !!window.app!.graph!.getNodeById('7')
|
||||
})
|
||||
expect(firstNodeExists).toBe(false)
|
||||
|
||||
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
|
||||
expect(secondNodeAfter).toEqual(secondNodeBefore)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.getNodeById('5')
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Removing the preview subgraph clears promoted preview state and DOM', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
|
||||
expect(beforePseudo.length).toBeGreaterThan(0)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.delete()
|
||||
|
||||
expect(await subgraphNode.exists()).toBe(false)
|
||||
|
||||
await expect
|
||||
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
|
||||
.toBe(0)
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,15 +3,8 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// Constants
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
nodeSearchContainer: '.node-search-container'
|
||||
} as const
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
@@ -39,15 +32,7 @@ function hasVisibleNodeInViewport() {
|
||||
}
|
||||
|
||||
test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Breadcrumb and Workflow Context', () => {
|
||||
test.describe('Subgraph Navigation and UI', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
@@ -62,18 +47,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
await expect(breadcrumb).toBeVisible({ timeout: 20_000 })
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -92,59 +71,40 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
|
||||
test('Switching workflows while inside subgraph returns to root graph context', async ({
|
||||
test('Switching workflows while inside subgraph returns to root graph context and hides the breadcrumb', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
const backButton = breadcrumb.locator('.back-button')
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
|
||||
await expect(backButton).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
await expect(backButton).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const breadcrumb = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.locator('.p-breadcrumb')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeVisible()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(breadcrumb).toBeHidden()
|
||||
await expect(backButton).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -157,7 +117,6 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Change the Exit Subgraph keybinding from Escape to Alt+Q
|
||||
await comfyPage.settings.setSetting('Comfy.Keybinding.NewBindings', [
|
||||
{
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
@@ -182,28 +141,26 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
}
|
||||
])
|
||||
|
||||
// Reload the page
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
).toBeVisible()
|
||||
|
||||
// Verify we're in a subgraph
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Test that Escape no longer exits subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
expect(
|
||||
await comfyPage.subgraph.isInSubgraph(),
|
||||
'Escape should stay inside the subgraph after the default binding is unset'
|
||||
).toBe(true)
|
||||
|
||||
// Test that Alt+Q now exits subgraph
|
||||
await comfyPage.page.keyboard.press('Alt+q')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
@@ -217,39 +174,36 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
).toBeVisible()
|
||||
|
||||
// Verify we're in a subgraph
|
||||
if (!(await comfyPage.subgraph.isInSubgraph())) {
|
||||
throw new Error('Not in subgraph')
|
||||
}
|
||||
expect(
|
||||
await comfyPage.subgraph.isInSubgraph(),
|
||||
'Precondition failed: expected to be inside the subgraph before opening settings'
|
||||
).toBe(true)
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.settings)
|
||||
).toBeVisible()
|
||||
|
||||
// Press Escape - should close dialog, not exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.settings)
|
||||
).toBeHidden()
|
||||
|
||||
// Should still be in subgraph
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Press Escape again - now should exit subgraph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Viewport', () => {
|
||||
test.describe('Subgraph viewport restoration', () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -258,24 +212,12 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (
|
||||
n as unknown as { isSubgraphNode: () => boolean }
|
||||
).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
timeout: 2_000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
@@ -293,7 +235,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
timeout: 2_000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
@@ -324,7 +266,7 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
@@ -336,61 +278,43 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Progress State', () => {
|
||||
test.describe('Subgraph progress clear on navigation', () => {
|
||||
test('Stale progress is cleared on subgraph node after navigating back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Find the subgraph node
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Simulate a stale progress value on the subgraph node.
|
||||
// This happens when:
|
||||
// 1. User views root graph during execution
|
||||
// 2. Progress watcher sets node.progress = 0.5
|
||||
// 3. User enters subgraph
|
||||
// 4. Execution completes (nodeProgressStates becomes {})
|
||||
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
|
||||
// SubgraphNode isn't visible so it keeps stale progress
|
||||
// 6. User navigates back — watcher should fire and clear it
|
||||
await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
|
||||
node.progress = 0.5
|
||||
}, subgraphNodeId)
|
||||
|
||||
// Verify progress is set
|
||||
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId)
|
||||
expect(progressBefore).toBe(0.5)
|
||||
|
||||
// Navigate into the subgraph
|
||||
const subgraphNode =
|
||||
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify we're inside the subgraph
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// Navigate back to the root graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The progress watcher should fire when graph changes (because
|
||||
// nodeLocationProgressStates is empty {} and the watcher should
|
||||
// iterate canvas.graph.nodes to clear stale node.progress values).
|
||||
//
|
||||
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
|
||||
// fire on subgraph->root navigation when progress is already empty,
|
||||
// leaving stale node.progress = 0.5 on the SubgraphNode.
|
||||
await expect(async () => {
|
||||
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId!)
|
||||
expect(progressAfter).toBeUndefined()
|
||||
}).toPass({ timeout: 2_000 })
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
|
||||
}, subgraphNodeId),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
|
||||
@@ -418,21 +342,23 @@ test.describe('Subgraph Navigation', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const subgraphProgressState = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) {
|
||||
return { exists: false, progress: null }
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' &&
|
||||
node.isSubgraphNode()
|
||||
)
|
||||
if (!subgraphNode) return { exists: false, progress: null }
|
||||
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
})
|
||||
expect(subgraphProgressState.exists).toBe(true)
|
||||
expect(subgraphProgressState.progress).toBeUndefined()
|
||||
}).toPass({ timeout: 5_000 })
|
||||
return { exists: true, progress: subgraphNode.progress }
|
||||
}),
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toEqual({ exists: true, progress: undefined })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,177 +4,44 @@ import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Nested subgraph configure order', () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
|
||||
test('Loads and queues without nested promotion resolution failures', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page,
|
||||
['No link found', 'Failed to resolve legacy -1']
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
test('All three subgraph levels resolve promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const results = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
|
||||
return allGraphs.flatMap((g) =>
|
||||
g._nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((hostNode) => {
|
||||
const proxyWidgets = Array.isArray(
|
||||
hostNode.properties?.proxyWidgets
|
||||
)
|
||||
? hostNode.properties.proxyWidgets
|
||||
: []
|
||||
|
||||
const widgetEntries = proxyWidgets
|
||||
.filter(
|
||||
(e: unknown): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string'
|
||||
)
|
||||
.map(([interiorNodeId, widgetName]: [string, string]) => {
|
||||
const sg = hostNode.isSubgraphNode()
|
||||
? hostNode.subgraph
|
||||
: null
|
||||
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
|
||||
return {
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
resolved:
|
||||
interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(hostNode.id),
|
||||
widgetEntries
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
results.length,
|
||||
'Should have subgraph host nodes at multiple nesting levels'
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const { hostNodeId, widgetEntries } of results) {
|
||||
expect(
|
||||
widgetEntries.length,
|
||||
`Host node ${hostNodeId} should have promoted widgets`
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
expect(widgetName).toBeTruthy()
|
||||
expect(
|
||||
resolved,
|
||||
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
const response = await responsePromise
|
||||
expect(warnings).toEqual([])
|
||||
expect(response.ok()).toBe(true)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Prompt execution succeeds without 400 error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const response = await responsePromise
|
||||
expect(response.status()).not.toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression tests for nested subgraph promotion where multiple interior
|
||||
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
|
||||
* with a "text" widget).
|
||||
*
|
||||
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
|
||||
* The outer subgraph (node 4) promotes through node 3 using identity
|
||||
* disambiguation (optional sourceNodeId in the promotion entry).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Inner subgraph node has both text widgets promoted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nonPreview = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
|
||||
return (
|
||||
(innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[]
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string' &&
|
||||
!entry[1].startsWith('$$')
|
||||
)
|
||||
.map(
|
||||
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
|
||||
)
|
||||
})
|
||||
|
||||
comfyExpect(nonPreview).toEqual([
|
||||
['1', 'text'],
|
||||
['2', 'text']
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -210,60 +77,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
})
|
||||
|
||||
test.describe('Promoted border styling in Vue mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 4 is the outer SubgraphNode at root level.
|
||||
// Its widgets are not promoted further (no parent subgraph),
|
||||
// so none of its widget wrappers should carry the promoted ring.
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const outerPromotedRings = outerNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(outerPromotedRings).toHaveCount(0)
|
||||
|
||||
// Navigate into the outer subgraph (node 4) to reach node 3
|
||||
await comfyPage.vueNodes.enterSubgraph('4')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Node 3 is the intermediate SubgraphNode whose "text" widgets
|
||||
// are promoted up to the outer subgraph (node 4).
|
||||
// Its widget wrappers should carry the promoted border ring.
|
||||
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
|
||||
await comfyExpect(intermediateNode).toBeVisible()
|
||||
|
||||
const intermediatePromotedRings = intermediateNode.locator(
|
||||
`.${PROMOTED_BORDER_CLASS}`
|
||||
)
|
||||
await comfyExpect(intermediatePromotedRings).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for PR #10532:
|
||||
* Packing all nodes inside a subgraph into a nested subgraph was causing
|
||||
* the parent subgraph node's promoted widget values to go blank.
|
||||
*
|
||||
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
|
||||
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
|
||||
* at promotion time). After repointing, input._widget still pointed to
|
||||
* removed node IDs, causing missing-node failures and blank values on the
|
||||
* next checkState cycle.
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph pack preserves promoted widget values',
|
||||
{ tag: ['@widget'] },
|
||||
@@ -284,7 +100,6 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
|
||||
// 1. Verify initial promoted widget values via Vue node DOM
|
||||
const widthWidget = nodeLocator
|
||||
.getByLabel('width', { exact: true })
|
||||
.first()
|
||||
@@ -310,10 +125,8 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
await comfyExpect(textWidget).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
// 2. Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// 6. Re-enable Vue nodes and verify values are preserved
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
@@ -345,87 +158,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
await comfyExpect(textAfter).toHaveValue(/Latina female/)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Verify the host node is visible
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
|
||||
// Pack all interior nodes into a nested subgraph
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
// Verify all proxyWidgets entries resolve
|
||||
await comfyExpect(async () => {
|
||||
const result = await comfyPage.page.evaluate((hostId) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(hostId)
|
||||
if (
|
||||
!hostNode ||
|
||||
typeof hostNode.isSubgraphNode !== 'function' ||
|
||||
!hostNode.isSubgraphNode()
|
||||
) {
|
||||
return { error: 'Host node not found or not a subgraph node' }
|
||||
}
|
||||
|
||||
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
|
||||
const entries = (proxyWidgets as unknown[])
|
||||
.filter(
|
||||
(e): e is [string, string] =>
|
||||
Array.isArray(e) &&
|
||||
e.length >= 2 &&
|
||||
typeof e[0] === 'string' &&
|
||||
typeof e[1] === 'string' &&
|
||||
!e[1].startsWith('$$')
|
||||
)
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(
|
||||
Number(nodeId)
|
||||
)
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
resolved: interiorNode !== null && interiorNode !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
return { entries, count: entries.length }
|
||||
}, HOST_NODE_ID)
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
const { entries, count } = result as {
|
||||
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
|
||||
count: number
|
||||
}
|
||||
expect(count).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(
|
||||
entry.resolved,
|
||||
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
|
||||
).toBe(true)
|
||||
}
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression test for nested subgraph packing leaving stale proxyWidgets
|
||||
* on the outer SubgraphNode.
|
||||
*
|
||||
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
|
||||
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
|
||||
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
|
||||
* Only ["3","seed"] (KSampler) should remain.
|
||||
*
|
||||
* Stale entries render as "Disconnected" placeholder widgets (type "button").
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
|
||||
*/
|
||||
test.describe(
|
||||
'Nested subgraph stale proxyWidgets',
|
||||
{ tag: ['@widget'] },
|
||||
@@ -447,12 +182,9 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
// Only the KSampler seed widget should be present — no stale
|
||||
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
|
||||
await comfyExpect(widgets).toHaveCount(1)
|
||||
await comfyExpect(widgets.first()).toBeVisible()
|
||||
|
||||
// Verify the seed widget is present via its label
|
||||
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -2,19 +2,19 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph Internal Operations',
|
||||
{ tag: ['@slow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
test.describe('Subgraph Clipboard Operations', () => {
|
||||
test('Can copy and paste nodes inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
@@ -22,45 +22,43 @@ test.describe(
|
||||
|
||||
const initialNodeCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodeId = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window.app!.canvas.graph!.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
expect(nodeId).not.toBeNull()
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
const nodeToClone = await comfyPage.nodeOps.getNodeRefById(String(nodeId))
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
test.describe('Subgraph History Operations', () => {
|
||||
test('Can undo and redo operations inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await comfyPage.subgraph.getNodeCount()
|
||||
|
||||
// Undo
|
||||
await comfyPage.keyboard.undo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -68,7 +66,6 @@ test.describe(
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.keyboard.redo()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -76,5 +73,5 @@ test.describe(
|
||||
.poll(() => comfyPage.subgraph.getNodeCount())
|
||||
.toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,25 +47,20 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select just the KSampler node (id 3) which has a "seed" widget
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SubgraphNode should exist
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
// 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)
|
||||
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
|
||||
|
||||
// SubgraphNode should have widgets (promoted views)
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
|
||||
})
|
||||
|
||||
test('CLIPTextEncode text widget is auto-promoted', async ({
|
||||
test('Preview-capable nodes keep regular and pseudo-widget promotions when converted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
@@ -113,29 +108,12 @@ test.describe(
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The subgraph node (id 11) should have a text widget promoted
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets all render on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
const count = await textareas.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
@@ -143,7 +121,7 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
@@ -151,57 +129,26 @@ test.describe(
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// SubgraphNode (id 11) should render with its body
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// It should have the Enter Subgraph button
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
// The promoted text widget should render inside the node
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
// Widgets section should exist and have at least one widget
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
const count = await widgets.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Value changes on promoted widget sync to interior widget', async ({
|
||||
test('Promoted and interior widgets stay in sync across navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
@@ -211,87 +158,30 @@ test.describe(
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
// Type into the promoted textarea on the SubgraphNode
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Interior CLIPTextEncode textarea should have the same value
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
})
|
||||
|
||||
test('Value changes on interior widget sync to promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'interior-value-sync-test'
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Type into the interior CLIPTextEncode textarea
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await interiorTextarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Promoted textarea on SubgraphNode should have the same value
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(testContent)
|
||||
})
|
||||
|
||||
test('Value persists through repeated navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'persistence-through-navigation'
|
||||
|
||||
// Set value on promoted widget
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
|
||||
// Navigate in and out multiple times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(testContent)
|
||||
}
|
||||
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -300,7 +190,7 @@ test.describe(
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Can promote a widget from inside a subgraph', async ({
|
||||
test('Can promote and un-promote a widget from inside a subgraph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
@@ -308,10 +198,9 @@ test.describe(
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get the KSampler node (id 1) inside the subgraph
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await ksampler.click('title')
|
||||
|
||||
// Right-click on the KSampler's "steps" widget (index 2) to promote it
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvas.click({
|
||||
@@ -360,12 +249,13 @@ test.describe(
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back and verify promotion took effect
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -373,12 +263,11 @@ test.describe(
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
||||
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
||||
|
||||
// Navigate back in and un-promote
|
||||
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode2.navigateIntoSubgraph()
|
||||
const stepsWidget2 = await (
|
||||
await comfyPage.nodeOps.getNodeRefById('1')
|
||||
).getWidget(2)
|
||||
const ksampler2 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await ksampler2.click('title')
|
||||
const stepsWidget2 = await ksampler2.getWidget(2)
|
||||
const widgetPos2 = await stepsWidget2.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
@@ -396,10 +285,8 @@ test.describe(
|
||||
await unpromoteEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should have fewer widgets
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'), {
|
||||
timeout: 5000
|
||||
@@ -422,26 +309,16 @@ test.describe(
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// Navigate into the subgraph (node id 11)
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// The interior CLIPTextEncode node (id 10) should render a textarea
|
||||
// widget in Vue mode. Right-click it to verify the contextmenu
|
||||
// event propagates correctly (fix from PR #9840) and shows the
|
||||
// ComfyUI context menu with "Promote Widget".
|
||||
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(clipNode).toBeVisible()
|
||||
|
||||
// Select the node first so the context menu builds correctly
|
||||
await comfyPage.vueNodes.selectNode('10')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Dispatch a contextmenu event directly on the textarea. A normal
|
||||
// right-click is intercepted by the z-999 canvas overlay, but the
|
||||
// Vue WidgetTextarea.vue handler listens on @contextmenu.capture,
|
||||
// so dispatching the event directly tests the fix from PR #9840.
|
||||
const textarea = clipNode.locator('textarea')
|
||||
await expect(textarea).toBeVisible()
|
||||
await textarea.dispatchEvent('contextmenu', {
|
||||
@@ -451,8 +328,6 @@ test.describe(
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The PrimeVue context menu should show "Promote Widget" since
|
||||
// the node is inside a subgraph (not the root graph).
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.locator('text=Promote Widget')
|
||||
@@ -462,7 +337,7 @@ test.describe(
|
||||
})
|
||||
|
||||
test.describe('Pseudo-Widget Promotion', () => {
|
||||
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
|
||||
test('Promoted preview nodes render custom content in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
@@ -506,11 +381,6 @@ test.describe(
|
||||
test.describe('Vue Mode - Promoted Preview Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
@@ -519,14 +389,35 @@ test.describe(
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
// The node body should exist
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames).toContain('filename_prefix')
|
||||
expect(promotedNames.some((name) => name.startsWith('$$'))).toBe(true)
|
||||
|
||||
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const loadImagePosition = await loadImageNode.getPosition()
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: loadImagePosition
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
await expect(
|
||||
nodeBody.locator('.lg-node-widgets > div').first()
|
||||
).toBeVisible()
|
||||
|
||||
await expect(nodeBody.locator('.image-preview img')).toHaveCount(1, {
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect(nodeBody.locator('.lg-node-widgets')).not.toContainText(
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
@@ -534,10 +425,6 @@ test.describe(
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
|
||||
// slot connected externally from the Outer node, so it should be
|
||||
// disabled. The remaining promoted textarea widgets (value, value_1)
|
||||
// are unlinked and should be enabled.
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
|
||||
expect(promotedNames).toContain('string_a')
|
||||
expect(promotedNames).toContain('value')
|
||||
@@ -553,29 +440,12 @@ test.describe(
|
||||
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
|
||||
expect(linkedWidget?.disabled).toBe(true)
|
||||
|
||||
const unlinkedWidgets = disabledState.filter(
|
||||
(w) => w.name !== 'string_a'
|
||||
)
|
||||
for (const w of unlinkedWidgets) {
|
||||
expect(w.disabled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The promoted textareas that are NOT externally linked should be
|
||||
// fully opaque and interactive.
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
@@ -588,8 +458,11 @@ test.describe(
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,218 +1,170 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
// Constants
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
await comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.rootGraph.nodes.find(
|
||||
(candidate) => String(candidate.id) === targetNodeId
|
||||
)
|
||||
if (!node || !('subgraph' in node) || !node.subgraph) {
|
||||
throw new Error(`Subgraph node ${targetNodeId} not found`)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Subgraph Promoted Widget DOM',
|
||||
{ tag: ['@slow', '@subgraph'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
window.app!.canvas.openSubgraph(node.subgraph, node)
|
||||
}, nodeId)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
return !!graph && 'inputNode' in graph
|
||||
}),
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
await parentTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Navigation and Persistence', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(subgraphCount).toBe(parentCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('DOM Cleanup', () => {
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
|
||||
await subgraphNode.delete()
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
const workflowName = 'subgraphs/subgraph-with-promoted-text-widget'
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const textareaCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(textareaCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.locator(
|
||||
'.p-breadcrumb-list > :first-child'
|
||||
)
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that the subgraph node has no widgets after removing the text slot
|
||||
const widgetCount = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.canvas.graph!.nodes[0].widgets?.length || 0
|
||||
})
|
||||
|
||||
expect(widgetCount).toBe(0)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('DOM Positioning', () => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect(async () => {
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
}).toPass({ timeout: 5000 })
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(parentCount)
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator
|
||||
.getByLabel('seed', { exact: true })
|
||||
.first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(parentCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ async function exitSubgraphAndPublish(
|
||||
name: blueprintName
|
||||
})
|
||||
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5_000 })
|
||||
await comfyPage.toast.closeToasts(1)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ async function searchAndExpectResult(
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await comfyPage.searchBox.input.fill(searchTerm)
|
||||
await expect(comfyPage.searchBox.findResult(expectedResult)).toBeVisible({
|
||||
timeout: 10000
|
||||
timeout: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,35 +49,22 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('Can set search aliases on subgraph and find via search', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Subgraph.SetSearchAliases', {
|
||||
aliases: 'qwerty,unicorn'
|
||||
})
|
||||
|
||||
const blueprintName = `test-aliases-${Date.now()}`
|
||||
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
|
||||
await searchAndExpectResult(comfyPage, 'unicorn', blueprintName)
|
||||
})
|
||||
|
||||
test('Can set description on subgraph', async ({ comfyPage }) => {
|
||||
await createSubgraphAndNavigateInto(comfyPage)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Subgraph.SetDescription', {
|
||||
description: 'This is a test description'
|
||||
})
|
||||
// Verify the description was set on the subgraph's extra
|
||||
|
||||
const description = await comfyPage.page.evaluate(() => {
|
||||
const subgraph = window['app']!.canvas.subgraph
|
||||
const subgraph = window.app!.canvas.subgraph
|
||||
return (subgraph?.extra as Record<string, unknown>)?.BlueprintDescription
|
||||
})
|
||||
|
||||
expect(description).toBe('This is a test description')
|
||||
})
|
||||
|
||||
test('Search aliases persist after publish and reload', async ({
|
||||
test('Published search aliases remain searchable after reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode = await createSubgraphAndNavigateInto(comfyPage)
|
||||
@@ -89,10 +76,9 @@ test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
|
||||
const blueprintName = `test-persist-${Date.now()}`
|
||||
await exitSubgraphAndPublish(comfyPage, subgraphNode, blueprintName)
|
||||
|
||||
// Reload the page to ensure aliases are persisted
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window['app'] && window['app'].extensionManager
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -1,433 +1,87 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { PromotedWidgetEntry } from '@e2e/helpers/promotedWidgets'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgets,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgetCount
|
||||
} from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
const expectPromotedWidgetsToResolveToInteriorNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
widgets: PromotedWidgetEntry[]
|
||||
) => {
|
||||
const interiorNodeIds = widgets.map(([id]) => id)
|
||||
const results = await comfyPage.page.evaluate(
|
||||
([hostId, ids]) => {
|
||||
const graph = window.app!.graph!
|
||||
const hostNode = graph.getNodeById(Number(hostId))
|
||||
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
||||
|
||||
return ids.map((id) => {
|
||||
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
||||
return interiorNode !== null && interiorNode !== undefined
|
||||
})
|
||||
},
|
||||
[hostSubgraphNodeId, interiorNodeIds] as const
|
||||
)
|
||||
|
||||
for (const exists of results) {
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
}
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
||||
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
|
||||
test('proxyWidgets entries survive double round-trip without drift', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(initialWidgets.length).toBeGreaterThan(0)
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
initialWidgets
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterFirst
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'11',
|
||||
afterSecond
|
||||
)
|
||||
|
||||
expect(afterFirst).toEqual(initialWidgets)
|
||||
expect(afterSecond).toEqual(initialWidgets)
|
||||
})
|
||||
|
||||
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
|
||||
for (const [interiorNodeId] of widgets) {
|
||||
expect(interiorNodeId).not.toBe('-1')
|
||||
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
await expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage,
|
||||
'2',
|
||||
widgets
|
||||
)
|
||||
})
|
||||
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(afterReload).toHaveCount(1)
|
||||
await expect(afterReload).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Legacy And Round-Trip Coverage', () => {
|
||||
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const widgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(widgets.some(([, widgetName]) => widgetName === 'batch_size')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Duplicate ID remap workflow remains navigable after a full reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Legacy prefixed proxyWidget normalization', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
||||
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
||||
).toBe(false)
|
||||
expect(
|
||||
promotedWidgets.some(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(beforePromoted).toContain('text')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(afterPromoted).toContain('text')
|
||||
|
||||
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
expect(widgetCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Multi-link input representative stays stable through save/reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPos = await originalNode.getPosition()
|
||||
|
||||
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
|
||||
expect(subgraphNodeIds.length).toBeGreaterThan(1)
|
||||
for (const nodeId of subgraphNodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Duplicate ID Remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
|
||||
// TODO: Extract allNodeIds accessor into LGraph
|
||||
const allGraphs = [graph, ...graph.subgraphs.values()]
|
||||
const allIds = allGraphs
|
||||
.flatMap((g) => g._nodes)
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
|
||||
return { allIds, uniqueCount: new Set(allIds).size }
|
||||
})
|
||||
|
||||
expect(result.uniqueCount).toBe(result.allIds.length)
|
||||
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
test('Root graph node IDs are preserved as canonical', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const rootIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
})
|
||||
|
||||
expect(rootIds).toEqual([1, 2, 5])
|
||||
})
|
||||
|
||||
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const beforeSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.page.waitForFunction(() => !!window.app)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const afterSnapshot =
|
||||
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
||||
expect(afterSnapshot).toEqual(beforeSnapshot)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('All links reference valid nodes in their graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const invalidLinks = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const labeledGraphs: [string, typeof graph][] = [
|
||||
['root', graph],
|
||||
...[...graph.subgraphs.entries()].map(
|
||||
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
||||
)
|
||||
]
|
||||
|
||||
const isNonNegative = (id: number | string) =>
|
||||
typeof id === 'number' && id >= 0
|
||||
|
||||
return labeledGraphs.flatMap(([label, g]) =>
|
||||
[...g._links.values()].flatMap((link) =>
|
||||
[
|
||||
isNonNegative(link.origin_id) &&
|
||||
!g._nodes_by_id[link.origin_id] &&
|
||||
`${label}: origin_id ${link.origin_id} not found`,
|
||||
isNonNegative(link.target_id) &&
|
||||
!g._nodes_by_id[link.target_id] &&
|
||||
`${label}: target_id ${link.target_id} not found`
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(invalidLinks).toEqual([])
|
||||
})
|
||||
|
||||
test('Subgraph navigation works after ID remapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
comfyExpect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test('Promoted widget renders with normalized name, not legacy prefix', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The promoted widget should render with the clean name "string_a",
|
||||
// not the legacy-prefixed "6: 3: string_a".
|
||||
const promotedWidget = outerNode
|
||||
.getByLabel('string_a', { exact: true })
|
||||
.first()
|
||||
await expect(promotedWidget).toBeVisible()
|
||||
})
|
||||
|
||||
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// Both widget rows should be valid "string_a" widgets — no stale
|
||||
// "Disconnected" placeholders from unresolved legacy entries.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widget value is editable as a text input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const textarea = outerNode
|
||||
.getByRole('textbox', { name: 'string_a' })
|
||||
.first()
|
||||
await expect(textarea).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,20 +5,18 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import {
|
||||
expectSlotsWithinBounds,
|
||||
measureNodeSlotOffsets
|
||||
} from '@e2e/fixtures/utils/slotBoundsUtil'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const RENAMED_NAME = 'renamed_slot_name'
|
||||
const RENAMED_SLOT_NAME = 'renamed_slot_name'
|
||||
const SECOND_RENAMED_NAME = 'second_renamed_name'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
@@ -32,7 +30,7 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('I/O Slot CRUD', () => {
|
||||
test.describe('I/O Slot Management', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
@@ -83,8 +81,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -103,8 +99,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.subgraph.removeSlot('output')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -112,10 +106,8 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
.poll(() => comfyPage.subgraph.getSlotCount('output'))
|
||||
.toBe(initialCount - 1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename', () => {
|
||||
test('Can rename I/O slots via right-click context menu', async ({
|
||||
test('Can rename an input slot from the context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
@@ -129,13 +121,10 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -239,26 +228,17 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
// Use direct pointer event approach to double-click on label
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
|
||||
const graph = app.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) {
|
||||
if (!graph || !('inputNode' in graph))
|
||||
throw new Error('Expected to be in subgraph')
|
||||
}
|
||||
|
||||
const input = graph.inputs?.[0]
|
||||
|
||||
if (!input?.labelPos) {
|
||||
if (!input?.labelPos)
|
||||
throw new Error('Could not get label position for testing')
|
||||
}
|
||||
|
||||
// Use labelPos for more precise clicking on the text
|
||||
const testX = input.labelPos[0]
|
||||
const testY = input.labelPos[1]
|
||||
|
||||
// Create a minimal mock event with required properties
|
||||
// Full PointerEvent creation is unnecessary for this test
|
||||
const leftClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
canvasX: input.labelPos[0],
|
||||
canvasY: input.labelPos[1],
|
||||
button: 0,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
@@ -272,7 +252,6 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
|
||||
// Trigger double-click if pointer has the handler
|
||||
if (app.canvas.pointer.onDoubleClick) {
|
||||
app.canvas.pointer.onDoubleClick(leftClickEvent)
|
||||
}
|
||||
@@ -281,25 +260,21 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
const labelClickRenamedName = 'label_click_renamed'
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, labelClickRenamedName)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
expect(newInputName).toBe(labelClickRenamedName)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename Dialog', () => {
|
||||
test.describe('Subgraph Slot Rename Dialog', () => {
|
||||
test('Shows current slot label (not stale) in rename dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -308,39 +283,27 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial slot label
|
||||
const initialInputLabel = await comfyPage.subgraph.getSlotLabel('input')
|
||||
|
||||
if (initialInputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an input slot label for rightClickInputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialInputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the rename worked
|
||||
const afterFirstRename = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph))
|
||||
@@ -352,42 +315,29 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
displayName: slot?.displayName || slot?.label || slot?.name || null
|
||||
}
|
||||
})
|
||||
expect(afterFirstRename.label).toBe(RENAMED_NAME)
|
||||
expect(afterFirstRename.label).toBe(RENAMED_SLOT_NAME)
|
||||
|
||||
// Now rename again - this is where the bug would show
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickInputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(
|
||||
SELECTORS.promptDialog
|
||||
)
|
||||
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
|
||||
|
||||
// Complete the second rename to ensure everything still works
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, SECOND_RENAMED_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the second rename worked
|
||||
const afterSecondRename = await comfyPage.subgraph.getSlotLabel('input')
|
||||
expect(afterSecondRename).toBe(SECOND_RENAMED_NAME)
|
||||
})
|
||||
@@ -400,173 +350,193 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Get initial output slot label
|
||||
const initialOutputLabel = await comfyPage.subgraph.getSlotLabel('output')
|
||||
|
||||
if (initialOutputLabel === null) {
|
||||
throw new Error(
|
||||
'Expected subgraph to have an output slot label for rightClickOutputSlot'
|
||||
)
|
||||
}
|
||||
|
||||
// First rename
|
||||
await comfyPage.subgraph.rightClickOutputSlot(initialOutputLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
// Clear and enter new name
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_NAME)
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_SLOT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for dialog to close
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now rename again to check for stale content
|
||||
// We need to use the index-based approach since the method looks for slot.name
|
||||
await comfyPage.subgraph.rightClickOutputSlot()
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
|
||||
// Get the current value in the prompt dialog
|
||||
const dialogValue = await comfyPage.page.inputValue(
|
||||
SELECTORS.promptDialog
|
||||
)
|
||||
|
||||
// This should show the current label (RENAMED_NAME), not the original name
|
||||
expect(dialogValue).toBe(RENAMED_NAME)
|
||||
expect(dialogValue).toBe(RENAMED_SLOT_NAME)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Rename Propagation', () => {
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe('Subgraph input slot rename propagation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/test-values-input-subgraph'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await comfyExpect(sgNode).toBeVisible()
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidget).toBeVisible()
|
||||
const seedWidget = subgraphNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
await SubgraphHelper.expectWidgetBelowHeader(subgraphNode, seedWidget)
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNode, seedWidget)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await subgraphNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const seedSlotName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
const inputs = (graph as { inputs?: Array<{ name: string }> }).inputs
|
||||
return (
|
||||
inputs?.find((input) => input.name.includes('seed'))?.name ?? null
|
||||
)
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = SELECTORS.promptDialog
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_LABEL)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await comfyExpect(sgNodeAfter).toBeVisible()
|
||||
const subgraphNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const updatedLabel = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
const widget = node.widgets?.find((entry: { name: string }) =>
|
||||
entry.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
return widget?.label || widget?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await comfyExpect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(sgNodeAfter, seedWidgetAfter)
|
||||
const seedWidgetAfter = subgraphNodeAfter.getByLabel('seed', {
|
||||
exact: true
|
||||
})
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
await expect(
|
||||
subgraphNodeAfter.getByText(RENAMED_LABEL, { exact: true })
|
||||
).toBeVisible()
|
||||
await SubgraphHelper.expectWidgetBelowHeader(
|
||||
subgraphNodeAfter,
|
||||
seedWidgetAfter
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Compressed target_slot', () => {
|
||||
test('Can create widget from link with compressed target_slot', async ({
|
||||
test.describe('Subgraph promoted widget-input slot position', () => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-compressed-target-slot'
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const step = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.nodes[0].widgets![0].options.step
|
||||
// Two frames needed: first renders slot changes, second stabilizes layout
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
// Two frames needed: first renders slot changes, second stabilizes layout
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(input: { type: string }) => input.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
expect(step).toBe(10)
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeVisible()
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, '')
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.page.locator(SELECTORS.promptDialog)).toBeHidden()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
expect(after!.widgetName).toBe(before!.widgetName)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slot Alignment', () => {
|
||||
/**
|
||||
* Regression test for link misalignment on SubgraphNodes when loading
|
||||
* workflows with workflowRendererVersion: "LG".
|
||||
*
|
||||
* Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows,
|
||||
* and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS
|
||||
* transform lags by a frame, causing clientPosToCanvasPos to produce wrong
|
||||
* slot offsets. The fix uses DOM-relative measurement instead.
|
||||
*/
|
||||
test.describe('Subgraph slot alignment after LG layout scale', () => {
|
||||
test('slot positions stay within node bounds after loading LG workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -610,91 +580,4 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Slot Position', () => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = SELECTORS.promptDialog
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await SubgraphHelper.getTextSlotPosition(
|
||||
comfyPage.page,
|
||||
'11'
|
||||
)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
66
docs/guidance/design-standards.md
Normal file
66
docs/guidance/design-standards.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
globs:
|
||||
- 'src/components/**/*.vue'
|
||||
- 'src/views/**/*.vue'
|
||||
---
|
||||
|
||||
# Comfy Design Standards
|
||||
|
||||
Applies when implementing or modifying user-facing components and views.
|
||||
|
||||
## Before Implementing UI Changes
|
||||
|
||||
Consult the **Comfy Design Standards** Figma file to ensure your changes follow the agreed-upon design principles. Use the Figma MCP tool to fetch the current standards:
|
||||
|
||||
```javascript
|
||||
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '0:1' })
|
||||
```
|
||||
|
||||
The Figma file is the single source of truth. Always fetch it live — do not rely on cached assumptions.
|
||||
|
||||
> **Note:** The Figma MCP is read-only. It cannot detect changes or diffs between versions. Always fetch the latest state before implementing.
|
||||
|
||||
### Key Sections
|
||||
|
||||
| Section | Node ID | When to consult |
|
||||
| -------------------- | --------- | ----------------------------------------------------------------------------- |
|
||||
| Hover States | `1:2` | Adding/modifying interactive elements (buttons, inputs, links, nav items) |
|
||||
| Click Targets | `4:243` | Adding clickable elements, especially small ones (icons, handles, connectors) |
|
||||
| Affordances | `15:2202` | Any interactive element — ensuring visual feedback on interaction |
|
||||
| Feedback | `15:2334` | User actions that need confirmation, success/error states |
|
||||
| Slips and How to Fix | `15:2337` | Error prevention, undo patterns, destructive actions |
|
||||
| Design Pillars | `15:2340` | New features, architectural UI decisions |
|
||||
| The User | `16:2348` | User flows, onboarding, accessibility |
|
||||
|
||||
Fetch the specific section relevant to your task:
|
||||
|
||||
```javascript
|
||||
get_figma_data({ fileKey: 'QreIv5htUaSICNuO2VBHw0', nodeId: '<node-id>' })
|
||||
```
|
||||
|
||||
## Figma Component Reference
|
||||
|
||||
The Figma file contains component specifications. When implementing these components, fetch details to match the design:
|
||||
|
||||
| Component | Component Set ID |
|
||||
| ----------------- | ---------------- |
|
||||
| Button/Default | `4:314` |
|
||||
| Search | `4:2366` |
|
||||
| Base Node Example | `4:4739` |
|
||||
|
||||
## Figma Token Translation Rules
|
||||
|
||||
When translating Figma design tokens into code:
|
||||
|
||||
- **Skip `-hover` and `-selected` suffixed tokens.** These states exist in Figma only for prototype demonstrations. On the frontend, hover and selected states must be derived programmatically (e.g., via `color-mix()` or Tailwind modifier classes like `hover:`).
|
||||
- **Color tier system:** Figma uses a tiered color hierarchy:
|
||||
- **Base** — default surface/background colors
|
||||
- **Secondary** — elevated surfaces (e.g., sidebars, cards)
|
||||
- **Tertiary** — elements on modal panels (one shade lighter than base)
|
||||
- Map Figma token names directly to Tailwind 4 semantic tokens — never hardcode hex values.
|
||||
|
||||
## Integration with Codebase
|
||||
|
||||
- Map Figma color values to Tailwind 4 semantic tokens — never hardcode hex values
|
||||
- Use `cn()` from `@/utils/tailwindUtil` for conditional class merging
|
||||
- Use the `dark:` avoidance rule from AGENTS.md — semantic tokens handle both themes
|
||||
@@ -9,6 +9,7 @@ import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
const UPLOAD_TIMEOUT_MS = 120_000
|
||||
|
||||
interface ImageUploadFormFields {
|
||||
/**
|
||||
@@ -30,7 +31,8 @@ const uploadFile = async (
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
body,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
if (resp.status !== 200) {
|
||||
@@ -88,7 +90,11 @@ export const useNodeImageUpload = (
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(String(error))
|
||||
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
||||
useToastStore().addAlert(t('g.uploadTimedOut'))
|
||||
} else {
|
||||
useToastStore().addAlert(String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
@@ -988,21 +990,43 @@ describe('SubgraphNode label propagation', () => {
|
||||
})
|
||||
|
||||
it('should propagate label via renaming-input event', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps', type: 'number' }]
|
||||
})
|
||||
|
||||
subgraph.addInput('steps', 'number')
|
||||
expect(subgraphNode.inputs[0].label).toBeUndefined()
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
const input = interiorNode.addInput('value', 'number')
|
||||
input.widget = { name: 'value' }
|
||||
interiorNode.addOutput('out', 'number')
|
||||
interiorNode.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const promotedInput = subgraphNode.inputs[0]
|
||||
const originalWidgetName = promotedInput.widget?.name
|
||||
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
|
||||
|
||||
expect(promotedInput.label).toBeUndefined()
|
||||
expect(promotedInput._widget).toBeDefined()
|
||||
|
||||
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
|
||||
|
||||
expect(subgraphNode.inputs[0].label).toBe('Steps Count')
|
||||
expect(subgraphNode.inputs[0].name).toBe('steps')
|
||||
expect(promotedInput.label).toBe('Steps Count')
|
||||
expect(promotedInput.name).toBe('steps')
|
||||
expect(promotedInput.widget?.name).toBe(originalWidgetName)
|
||||
expect(promotedInput._widget?.label).toBe('Steps Count')
|
||||
expect(subgraphNode.widgets?.[0].label).toBe('Steps Count')
|
||||
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
|
||||
nodeId: subgraphNode.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
})
|
||||
|
||||
it('should propagate label via renaming-output event', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
|
||||
|
||||
subgraph.addOutput('result', 'number')
|
||||
expect(subgraphNode.outputs[0].label).toBeUndefined()
|
||||
@@ -1011,6 +1035,10 @@ describe('SubgraphNode label propagation', () => {
|
||||
|
||||
expect(subgraphNode.outputs[0].label).toBe('Final Result')
|
||||
expect(subgraphNode.outputs[0].name).toBe('result')
|
||||
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
|
||||
nodeId: subgraphNode.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve localized_name from configure path', () => {
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* Tests for saving, loading, and version compatibility of subgraphs.
|
||||
* This covers serialization, deserialization, data integrity, and migration scenarios.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
class DummyNode extends LGraphNode {}
|
||||
|
||||
const DUPLICATE_ID_SUBGRAPH_A = '11111111-1111-4111-8111-111111111111'
|
||||
const DUPLICATE_ID_SUBGRAPH_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function createRegisteredNode(
|
||||
graph: LGraph | Subgraph,
|
||||
inputs: ISlotType[] = [],
|
||||
@@ -50,6 +56,11 @@ function createRegisteredNode(
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
LiteGraph.registerNodeType('dummy', DummyNode)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete LiteGraph.registered_node_types.dummy
|
||||
})
|
||||
|
||||
describe('SubgraphSerialization - Basic Serialization', () => {
|
||||
@@ -475,4 +486,57 @@ describe('SubgraphSerialization - Data Integrity', () => {
|
||||
expect(targetNode!.inputs[link.target_slot]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates duplicate subgraph node IDs while keeping root nodes canonical', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(duplicateSubgraphNodeIds))
|
||||
|
||||
const rootIds = graph.nodes
|
||||
.map((node) => node.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
.sort((a, b) => a - b)
|
||||
expect(rootIds).toEqual([102, 103])
|
||||
|
||||
const subgraphAIds = new Set(
|
||||
graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_A)!.nodes.map((node) => node.id)
|
||||
)
|
||||
const subgraphBIds = new Set(
|
||||
graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!.nodes.map((node) => node.id)
|
||||
)
|
||||
|
||||
expect(subgraphAIds).toEqual(new Set([3, 8, 37]))
|
||||
for (const id of subgraphAIds) {
|
||||
expect(subgraphBIds.has(id)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches remapped link and proxyWidget references during duplicate-ID hydration', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(duplicateSubgraphNodeIds))
|
||||
|
||||
const subgraphAIds = new Set(
|
||||
graph.subgraphs
|
||||
.get(DUPLICATE_ID_SUBGRAPH_A)!
|
||||
.nodes.map((node) => String(node.id))
|
||||
)
|
||||
const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!
|
||||
const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id)))
|
||||
|
||||
const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(rootProxyWidgetsA)).toBe(true)
|
||||
for (const entry of rootProxyWidgetsA as string[][]) {
|
||||
expect(subgraphAIds.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(rootProxyWidgetsB)).toBe(true)
|
||||
for (const entry of rootProxyWidgetsB as string[][]) {
|
||||
expect(subgraphBIds.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
for (const [, link] of subgraphB.links) {
|
||||
expect(subgraphBIds.has(String(link.origin_id))).toBe(true)
|
||||
expect(subgraphBIds.has(String(link.target_id))).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -266,6 +266,40 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
|
||||
describe('Nested Subgraph Widget Promotion', () => {
|
||||
it('should hydrate legacy -1 proxyWidgets to a concrete promoted widget with preserved options', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'batch_size', type: 'INT' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('EmptyLatentImage')
|
||||
const interiorInput = interiorNode.addInput('batch_size', 'INT')
|
||||
interiorNode.addOutput('LATENT', 'LATENT')
|
||||
interiorNode.addWidget('number', 'batch_size', 1, () => {}, {
|
||||
step: 10,
|
||||
min: 1
|
||||
})
|
||||
interiorInput.widget = { name: 'batch_size' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const serializedHostNode = hostNode.serialize()
|
||||
serializedHostNode.properties = {
|
||||
...serializedHostNode.properties,
|
||||
proxyWidgets: [['-1', 'batch_size']]
|
||||
}
|
||||
|
||||
hostNode.configure(serializedHostNode)
|
||||
|
||||
expect(hostNode.properties.proxyWidgets).toStrictEqual([
|
||||
[String(interiorNode.id), 'batch_size']
|
||||
])
|
||||
expect(hostNode.widgets).toHaveLength(1)
|
||||
expect(hostNode.widgets[0].name).toBe('batch_size')
|
||||
expect(hostNode.widgets[0].value).toBe(1)
|
||||
expect(hostNode.widgets[0].options.step).toBe(10)
|
||||
})
|
||||
|
||||
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
|
||||
// Reproduces the bug where packing nodes into a nested subgraph leaves
|
||||
// stale proxyWidgets on the outer subgraph node referencing grandchild
|
||||
@@ -378,6 +412,47 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve promoted widget entries after cloning', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
interiorNode.addOutput('CONDITIONING', 'CONDITIONING')
|
||||
interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: 'text' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// serialize() syncs the promotion store into properties.proxyWidgets
|
||||
const serialized = hostNode.serialize()
|
||||
const originalProxyWidgets = serialized.properties!
|
||||
.proxyWidgets as string[][]
|
||||
|
||||
expect(originalProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
originalProxyWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
|
||||
// Simulate clone: create a second SubgraphNode configured from serialized data
|
||||
const cloneNode = createTestSubgraphNode(subgraph)
|
||||
cloneNode.configure(serialized)
|
||||
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
|
||||
|
||||
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
|
||||
// Clone's proxyWidgets should reference the same interior node
|
||||
const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId)
|
||||
const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId)
|
||||
expect(cloneNodeIds).toStrictEqual(originalNodeIds)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Promotion', () => {
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"control_before_generate": "control before generate",
|
||||
"choose_file_to_upload": "choose file to upload",
|
||||
"uploadAlreadyInProgress": "Upload already in progress",
|
||||
"uploadTimedOut": "Upload timed out. Please try again.",
|
||||
"capture": "capture",
|
||||
"nodes": "Nodes",
|
||||
"nodesCount": "{count} node | {count} nodes",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
v-if="!videoError"
|
||||
:src="currentVideoUrl"
|
||||
:class="cn('block size-full object-contain', showLoader && 'invisible')"
|
||||
preload="metadata"
|
||||
controls
|
||||
loop
|
||||
playsinline
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:key="index"
|
||||
:src="url"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ st('nodeErrors.content', 'Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content flex flex-auto grow flex-col">
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-content flex flex-auto grow flex-col [content-visibility:auto]"
|
||||
>
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<VideoPreview
|
||||
|
||||
@@ -67,6 +67,7 @@ vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
|
||||
describe('useSubgraphNavigationStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.ds.scale = 1
|
||||
@@ -74,7 +75,6 @@ describe('useSubgraphNavigationStore', () => {
|
||||
app.canvas.ds.state.scale = 1
|
||||
app.canvas.ds.state.offset = [0, 0]
|
||||
app.graph.getNodeById = vi.fn()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should not clear navigation stack when workflow internal state changes', async () => {
|
||||
@@ -82,11 +82,11 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Mock a workflow
|
||||
const mockWorkflow = {
|
||||
const mockWorkflow = fromPartial<ComfyWorkflow>({
|
||||
path: 'test-workflow.json',
|
||||
filename: 'test-workflow.json',
|
||||
changeTracker: null
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
// Set the active workflow (cast to bypass TypeScript check in test)
|
||||
workflowStore.activeWorkflow =
|
||||
@@ -113,15 +113,15 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
const workflow1 = {
|
||||
const workflow1 = fromPartial<ComfyWorkflow>({
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json'
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
const workflow2 = {
|
||||
const workflow2 = fromPartial<ComfyWorkflow>({
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json'
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
const sub1 = createMockSubgraph('sub-1')
|
||||
const sub2 = createMockSubgraph('sub-2')
|
||||
@@ -165,10 +165,10 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
const workflow1 = {
|
||||
const workflow1 = fromPartial<ComfyWorkflow>({
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json'
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
const workflow1Subgraph = createMockSubgraph('sub-1')
|
||||
|
||||
@@ -185,10 +185,10 @@ describe('useSubgraphNavigationStore', () => {
|
||||
|
||||
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
|
||||
|
||||
const workflow2 = {
|
||||
const workflow2 = fromPartial<ComfyWorkflow>({
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json'
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
app.canvas.subgraph = undefined
|
||||
|
||||
@@ -216,6 +216,32 @@ describe('useSubgraphNavigationStore', () => {
|
||||
expect(navigationStore.navigationStack).toEqual([])
|
||||
})
|
||||
|
||||
it('should fall back to the active subgraph id when path lookup fails during navigation updates', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
|
||||
|
||||
const unreachableSubgraph = createMockSubgraph('orphan-subgraph', app.graph)
|
||||
|
||||
app.graph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
|
||||
vi.mocked(findSubgraphPathById).mockReturnValue(null)
|
||||
|
||||
const mockWorkflow = fromPartial<ComfyWorkflow>({
|
||||
path: 'test-workflow.json',
|
||||
filename: 'test-workflow.json'
|
||||
})
|
||||
|
||||
workflowStore.activeWorkflow =
|
||||
mockWorkflow as typeof workflowStore.activeWorkflow
|
||||
|
||||
app.canvas.subgraph = unreachableSubgraph
|
||||
workflowStore.updateActiveGraph()
|
||||
await nextTick()
|
||||
|
||||
expect(navigationStore.exportState()).toEqual([unreachableSubgraph.id])
|
||||
expect(navigationStore.navigationStack).toEqual([unreachableSubgraph])
|
||||
})
|
||||
|
||||
it('should clear navigation when activeSubgraph becomes undefined', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -228,10 +254,10 @@ describe('useSubgraphNavigationStore', () => {
|
||||
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
|
||||
// First set an active workflow
|
||||
const mockWorkflow = {
|
||||
const mockWorkflow = fromPartial<ComfyWorkflow>({
|
||||
path: 'test-workflow.json',
|
||||
filename: 'test-workflow.json'
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
workflowStore.activeWorkflow =
|
||||
mockWorkflow as typeof workflowStore.activeWorkflow
|
||||
|
||||
Reference in New Issue
Block a user