mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
## Summary Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for surfacing inner subgraph widgets on the parent SubgraphNode by *promoting through links* rather than by duplicating widget state on the host. Ships with the hygiene/refactor pass on the migration, store, and event layers that the new model depends on. ## What changes ### Subgraph Link Only Promotion (ADR 0009) Promoted widgets are defined by the link from a SubgraphNode input to the interior node, not by a duplicated widget instance on the host. Consequences: - A SubgraphNode renders inner widgets purely as a **projection** of the interior widgets and links — no host-side state to drift. - **Per-host independence**: multiple instances of the same SubgraphNode render and edit their own values without cross-talk. - **Reversible promote/demote**: structural link operation, so demote preserves host slots and external connections (#12278). ### Supporting refactors - **Migration** — Planner/classifier/repair/quarantine helpers collapsed into a single `proxyWidgetMigration` entry point with black-box round-trip coverage. Honors the source-node-id disambiguator on `proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to the right interior widget. - **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted widget state is keyed by entityId across the store, DOM, and migration paths. - **SubgraphNode** — 3-key promoted-view cache replaced with a single version counter + explicit `invalidatePromotedViews()` at mutation sites; `id === -1` sentinel removed. - **Events** — `LGraph.trigger()` now dispatches node trigger payloads through `this.events`, replacing a leaky `onTrigger` monkey-patch. `SubgraphEditor` reactivity is driven from subgraph events instead of imperative refresh. - **Stores** — `appModeStore` migration helpers collapsed into `upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from the locator index; `previewExposureStore` cleanup and cycle-detection double-warn fix. - **Misc** — `Outcome` types consolidated; mutable accumulators replaced with `flatMap`; new ESLint rule forbids litegraph imports under `src/world/`. ### Tests - Browser tests for promoted widgets retagged `@vue-nodes` and rewritten to assert against the rendered Vue node DOM (via `getNodeLocator` / `getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate` graph introspection. - Per-host widget independence asserted via DOM. - Migration coverage moved to black-box round-trip tests. - Added coverage for duplicate-named promoted widget identity (ADR 0009) and the per-parent demote branch in `WidgetActions`. ## Review focus - ADR 0009 conformance of the link-only promotion model. - Disambiguator resolution path in `proxyWidgetMigration`. - Single-version-counter promoted-view cache and its `invalidatePromotedViews()` call sites. - `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue` migration off `onTrigger` (FE-667 tracks the remaining `useGraphNodeManager` conversion). ## Breaking changes None for users. Internal subgraph promotion APIs changed — see ADR 0009. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com> Co-authored-by: AustinMroz <austin@comfy.org>
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
import type { Locator, Page } from '@playwright/test'
|
|
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
|
|
|
/**
|
|
* Helper for interacting with widgets rendered in app mode (linear view).
|
|
*
|
|
* Widgets are located by `nodeId:widgetName` suffix against the
|
|
* `data-widget-key` attribute, which carries the canonical
|
|
* `graphId:nodeId:widgetName` WidgetEntityId.
|
|
*/
|
|
export class AppModeWidgetHelper {
|
|
constructor(private readonly comfyPage: ComfyPage) {}
|
|
|
|
private get page(): Page {
|
|
return this.comfyPage.page
|
|
}
|
|
|
|
private get container(): Locator {
|
|
return this.comfyPage.appMode.linearWidgets
|
|
}
|
|
|
|
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
|
getWidgetItem(key: string): Locator {
|
|
return this.container.locator(`[data-widget-key$=":${key}"]`)
|
|
}
|
|
|
|
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
|
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
|
|
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
|
|
}
|
|
|
|
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
|
|
async fillTextarea(key: string, value: string) {
|
|
const widget = this.getWidgetItem(key)
|
|
await widget.locator('textarea').fill(value)
|
|
}
|
|
|
|
/**
|
|
* Set a number input widget value (INT or FLOAT).
|
|
* Targets the last input inside the widget — this works for both
|
|
* ScrubableNumberInput (single input) and slider+InputNumber combos
|
|
* (last input is the editable number field).
|
|
*/
|
|
async fillNumber(key: string, value: string) {
|
|
const widget = this.getWidgetItem(key)
|
|
const input = widget.locator('input').last()
|
|
await input.fill(value)
|
|
await input.press('Enter')
|
|
}
|
|
|
|
/** Fill a string text input widget (e.g. filename_prefix). */
|
|
async fillText(key: string, value: string) {
|
|
const widget = this.getWidgetItem(key)
|
|
await widget.locator('input').fill(value)
|
|
}
|
|
|
|
/** Select an option from a combo/select widget. */
|
|
async selectOption(key: string, optionName: string) {
|
|
const widget = this.getWidgetItem(key)
|
|
await widget.getByRole('combobox').click()
|
|
await this.page
|
|
.getByRole('option', { name: optionName, exact: true })
|
|
.click()
|
|
}
|
|
|
|
/**
|
|
* Intercept the /api/prompt POST, click Run, and return the prompt payload.
|
|
* Fulfills the route with a mock success response.
|
|
*/
|
|
async runAndCapturePrompt(): Promise<
|
|
Record<string, { inputs: Record<string, unknown> }>
|
|
> {
|
|
let promptBody: Record<string, { inputs: Record<string, unknown> }> | null =
|
|
null
|
|
await this.page.route(
|
|
'**/api/prompt',
|
|
async (route, req) => {
|
|
promptBody = req.postDataJSON().prompt
|
|
await route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
prompt_id: 'test-id',
|
|
number: 1,
|
|
node_errors: {}
|
|
})
|
|
})
|
|
},
|
|
{ times: 1 }
|
|
)
|
|
|
|
const responsePromise = this.page.waitForResponse('**/api/prompt')
|
|
await this.comfyPage.appMode.runButton.click()
|
|
await responsePromise
|
|
|
|
if (!promptBody) throw new Error('No prompt payload captured')
|
|
return promptBody
|
|
}
|
|
}
|