mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 19:54:32 +00:00
## Summary
Fixes FE-1014 by making legacy LiteGraph honor backend-provided hidden
widget metadata.
This PR adds a regression test for the Painter node and updates
LiteGraph widget construction so that a backend input spec with `hidden:
true` is mirrored onto the top-level `widget.hidden` property that the
legacy canvas renderer actually reads.
## Problem
Backend node definitions can mark inputs as hidden, for example with
`extra_dict={"hidden": True}`. That metadata already flows into the
frontend widget options as `widget.options.hidden`, which is why Vue
nodes correctly hide those fields.
Legacy LiteGraph, however, does not use `widget.options.hidden` for
canvas visibility. Its rendering, layout, and hit-testing paths check
top-level `widget.hidden` instead. As a result, a field could be hidden
in Vue nodes while still appearing as an editable control in the legacy
LiteGraph canvas.
For affected nodes, this exposes fields that are intended to be
implementation details, schema/version values, or other non-user-facing
inputs.
## Root Cause
The frontend widget construction path copied backend display metadata
into `widget.options`, including:
- `advanced`
- `hidden`
But it did not mirror backend `hidden` metadata into `widget.hidden`.
That created a renderer split:
- Vue nodes and the right panel use `widget.options.hidden`.
- Legacy LiteGraph uses top-level `widget.hidden`.
So backend-hidden widgets were hidden in Vue mode but still visible and
clickable in legacy LiteGraph mode.
## Implementation
The production change is intentionally small and scoped to
backend-provided hidden metadata:
- Continue assigning `inputSpec.hidden` to `widget.options.hidden` as
before.
- When `inputSpec.hidden` is explicitly defined, also assign it to
top-level `widget.hidden`.
This keeps Vue behavior unchanged while making the legacy LiteGraph
renderer receive the same backend hidden signal through the field it
already uses for visibility.
The fix deliberately does not mirror `advanced` into top-level
`widget.advanced`. While investigating this area, I found that many
backend inputs define `advanced`, and changing legacy advanced-widget
behavior would be a much broader behavioral change than FE-1014
requires. This PR only addresses hidden metadata.
## Test Coverage
This PR adds and tightens Painter regression coverage because Painter
currently provides a concrete backend-hidden widget case:
- In Vue mode, the test verifies hidden Painter widgets are not rendered
to the user.
- In legacy LiteGraph mode, the test disables Vue nodes, loads the
Painter workflow, clicks the rows where backend-hidden number widgets
used to be exposed, and verifies the legacy graph editor dialog does not
open.
The legacy test specifically covers the backend-hidden number widgets
`width` and `height`. It uses user-observable behavior rather than
asserting internal widget flags directly.
A follow-up discussion is ongoing about the broader contract between
`widget.options.hidden` and top-level `widget.hidden`, especially for
frontend-extension-only hiding such as Painter `bg_color`. This PR
intentionally keeps that broader renderer-contract question out of scope
and focuses on backend `hidden` metadata from FE-1014.
## Validation
Validated locally with targeted Playwright coverage:
```bash
PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/painter.spec.ts --project=chromium -g "Does not render hidden standard widgets|Does not open editors for backend-hidden number widget rows"
```
Result:
```text
2 passed
```
Also validated with linting:
```bash
pnpm eslint src/services/litegraphService.ts browser_tests/tests/painter.spec.ts
pnpm eslint browser_tests/tests/painter.spec.ts
```
The commit hooks also passed:
- `oxfmt`
- `oxlint`
- `eslint`
- `pnpm typecheck`
- `pnpm typecheck:browser`
## Notes
The new legacy test was confirmed red before the production fix and
green after the production fix, so it is not a vacuous assertion. The
final cleanup commit only tightens test naming and coordinate handling
while preserving the same regression intent.
246 lines
7.4 KiB
TypeScript
246 lines
7.4 KiB
TypeScript
import { readFileSync } from 'fs'
|
|
|
|
import { test } from '@playwright/test'
|
|
|
|
import type { AppMode } from '@/utils/appMode'
|
|
import type {
|
|
ComfyApiWorkflow,
|
|
ComfyWorkflowJSON
|
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import type { WorkspaceStore } from '@e2e/types/globals'
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import { assetPath } from '@e2e/fixtures/utils/paths'
|
|
|
|
type FolderStructure = {
|
|
[key: string]: FolderStructure | string
|
|
}
|
|
|
|
export class WorkflowHelper {
|
|
constructor(private readonly comfyPage: ComfyPage) {}
|
|
|
|
convertLeafToContent(structure: FolderStructure): FolderStructure {
|
|
const result: FolderStructure = {}
|
|
|
|
for (const [key, value] of Object.entries(structure)) {
|
|
if (typeof value === 'string') {
|
|
const filePath = assetPath(value)
|
|
result[key] = readFileSync(filePath, 'utf-8')
|
|
} else {
|
|
result[key] = this.convertLeafToContent(value)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
async setupWorkflowsDirectory(structure: FolderStructure) {
|
|
const resp = await this.comfyPage.request.post(
|
|
`${this.comfyPage.url}/api/devtools/setup_folder_structure`,
|
|
{
|
|
data: {
|
|
tree_structure: this.convertLeafToContent(structure),
|
|
base_path: `user/${this.comfyPage.id}/workflows`
|
|
}
|
|
}
|
|
)
|
|
|
|
if (resp.status() !== 200) {
|
|
throw new Error(
|
|
`Failed to setup workflows directory: ${await resp.text()}`
|
|
)
|
|
}
|
|
|
|
await this.comfyPage.page.evaluate(async () => {
|
|
await (
|
|
window.app!.extensionManager as WorkspaceStore
|
|
).workflow.syncWorkflows()
|
|
})
|
|
|
|
// Wait for Vue to re-render the workflow list
|
|
await this.comfyPage.nextFrame()
|
|
}
|
|
|
|
async waitForDraftPersisted() {
|
|
await this.comfyPage.page.waitForFunction(() =>
|
|
Object.keys(localStorage).some((key) =>
|
|
key.startsWith('Comfy.Workflow.Draft.v2:')
|
|
)
|
|
)
|
|
}
|
|
|
|
/** Waits for V2 draft index recency, not payload content freshness. */
|
|
async waitForDraftIndexUpdatedSince(updatedSince: number) {
|
|
await this.comfyPage.page.waitForFunction((indexUpdatedSince) => {
|
|
for (let i = 0; i < window.localStorage.length; i++) {
|
|
const key = window.localStorage.key(i)
|
|
if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue
|
|
|
|
const json = window.localStorage.getItem(key)
|
|
if (!json) continue
|
|
|
|
try {
|
|
const index = JSON.parse(json)
|
|
if (
|
|
typeof index.updatedAt === 'number' &&
|
|
index.updatedAt >= indexUpdatedSince
|
|
) {
|
|
return true
|
|
}
|
|
} catch {
|
|
// Ignore malformed storage while waiting for persistence.
|
|
}
|
|
}
|
|
|
|
return false
|
|
}, updatedSince)
|
|
}
|
|
|
|
/**
|
|
* Reloads the current page and waits for the app to initialize.
|
|
* Unlike ComfyPage.setup(), this preserves localStorage (drafts) and
|
|
* the URL hash (subgraph navigation state), so the app restores
|
|
* exactly where the user left off.
|
|
*/
|
|
async reloadAndWaitForApp() {
|
|
await this.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
|
await this.comfyPage.waitForAppReady()
|
|
}
|
|
|
|
async loadGraphData(workflow: ComfyWorkflowJSON): Promise<void> {
|
|
await this.comfyPage.page.evaluate(
|
|
(wf) => window.app!.loadGraphData(wf),
|
|
workflow
|
|
)
|
|
await this.comfyPage.nextFrame()
|
|
}
|
|
|
|
async loadWorkflow(workflowName: string) {
|
|
await this.comfyPage.workflowUploadInput.setInputFiles(
|
|
assetPath(`${workflowName}.json`)
|
|
)
|
|
await this.waitForWorkflowIdle()
|
|
await this.comfyPage.nextFrame()
|
|
if (test.info().tags.includes('@vue-nodes')) {
|
|
await this.comfyPage.vueNodes.waitForNodes()
|
|
}
|
|
}
|
|
|
|
async deleteWorkflow(
|
|
workflowName: string,
|
|
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
|
|
) {
|
|
// Open workflows tab
|
|
const { workflowsTab } = this.comfyPage.menu
|
|
await workflowsTab.open()
|
|
|
|
// Action to take if workflow missing
|
|
if (whenMissing === 'ignoreMissing') {
|
|
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
|
|
if (!workflows.includes(workflowName)) return
|
|
}
|
|
|
|
// Delete workflow
|
|
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
|
|
await this.comfyPage.contextMenu.clickMenuItem('Delete')
|
|
await this.comfyPage.nextFrame()
|
|
await this.comfyPage.confirmDialog.delete.click()
|
|
|
|
// Clear toast & close tab
|
|
await this.comfyPage.toast.closeToasts(1)
|
|
await workflowsTab.close()
|
|
}
|
|
|
|
async getUndoQueueSize(): Promise<number | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow
|
|
return workflow?.changeTracker.undoQueue.length
|
|
})
|
|
}
|
|
|
|
async getRedoQueueSize(): Promise<number | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow
|
|
return workflow?.changeTracker.redoQueue.length
|
|
})
|
|
}
|
|
|
|
async waitForActiveWorkflow(): Promise<void> {
|
|
await this.comfyPage.page.waitForFunction(
|
|
() =>
|
|
(window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow !== null
|
|
)
|
|
}
|
|
|
|
async getActiveWorkflowPath(): Promise<string | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
return (window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow?.path
|
|
})
|
|
}
|
|
|
|
async getActiveWorkflowActiveAppMode(): Promise<AppMode | null | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
return (window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow?.activeMode
|
|
})
|
|
}
|
|
|
|
async getActiveWorkflowInitialMode(): Promise<AppMode | null | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
return (window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow?.initialMode
|
|
})
|
|
}
|
|
|
|
async getLinearModeFromGraph(): Promise<boolean | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
return window.app!.rootGraph.extra?.linearMode as boolean | undefined
|
|
})
|
|
}
|
|
|
|
async getOpenWorkflowCount(): Promise<number> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
return (window.app!.extensionManager as WorkspaceStore).workflow.workflows
|
|
.length
|
|
})
|
|
}
|
|
|
|
async isCurrentWorkflowModified(): Promise<boolean | undefined> {
|
|
return this.comfyPage.page.evaluate(() => {
|
|
return (window.app!.extensionManager as WorkspaceStore).workflow
|
|
.activeWorkflow?.isModified
|
|
})
|
|
}
|
|
|
|
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
|
|
await this.comfyPage.page.waitForFunction(
|
|
() =>
|
|
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
|
|
?.isBusy,
|
|
undefined,
|
|
{ timeout }
|
|
)
|
|
}
|
|
|
|
async switchToTab(tabName: string): Promise<void> {
|
|
await this.comfyPage.menu.topbar.getWorkflowTab(tabName).click()
|
|
await this.waitForWorkflowIdle()
|
|
}
|
|
|
|
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>
|
|
async getExportedWorkflow(options?: {
|
|
api?: false
|
|
}): Promise<ComfyWorkflowJSON>
|
|
async getExportedWorkflow(options?: {
|
|
api?: boolean
|
|
}): Promise<ComfyWorkflowJSON | ComfyApiWorkflow> {
|
|
const api = options?.api ?? false
|
|
return this.comfyPage.page.evaluate(async (api) => {
|
|
return (await window.app!.graphToPrompt())[api ? 'output' : 'workflow']
|
|
}, api)
|
|
}
|
|
}
|