mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
Merge branch 'main' into test/bottom-panel-e2e
This commit is contained in:
@@ -84,6 +84,7 @@
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
|
||||
@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const features = computed(() => [
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs tracking-widest text-brand-yellow uppercase"
|
||||
>
|
||||
{{ t('academy.badge', locale) }}
|
||||
</span>
|
||||
|
||||
@@ -40,7 +40,7 @@ const steps = computed(() => [
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
class="absolute top-8 right-0 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
|
||||
@@ -31,11 +31,11 @@ const ctaButtons = computed(() => [
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
class="size-64 rounded-full border-40 border-brand-yellow md:h-112 md:w-md md:border-64 lg:h-144 lg:w-xl lg:border-80"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
class="absolute top-1/2 right-0 h-32 w-24 translate-x-1/2 -translate-y-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@ const ctaButtons = computed(() => [
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
class="text-5xl/tight font-bold tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
{{ t('hero.headline', locale) }}
|
||||
</h1>
|
||||
|
||||
@@ -17,7 +17,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('manifesto.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg/relaxed text-smoke-700">
|
||||
{{ t('manifesto.body', locale) }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ const features = computed(() => [
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
class="flex size-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
class="ml-1 size-0 border-y-8 border-l-14 border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">
|
||||
@@ -54,7 +54,7 @@ const features = computed(() => [
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
class="size-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ const metrics = computed(() => [
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
class="text-center text-xs font-medium tracking-widest text-smoke-700 uppercase"
|
||||
>
|
||||
{{ t('social.heading', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -90,7 +90,7 @@ const filteredTestimonials = computed(() => {
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
<blockquote class="text-base text-white italic">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ const activeCategory = ref(0)
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
class="aspect-2/3 rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<div class="flex flex-col items-center text-center lg:flex-2">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
{{ t('useCase.heading', locale) }}
|
||||
</h2>
|
||||
@@ -70,7 +70,7 @@ const activeCategory = ref(0)
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
class="aspect-2/3 rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ const pillars = computed(() => [
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
class="flex size-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,39 @@ browser_tests/
|
||||
- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading).
|
||||
- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere.
|
||||
|
||||
## Page Object Locator Style
|
||||
|
||||
Define UI element locators as `public readonly` properties assigned in the constructor — not as getter methods. Getters that simply return a locator add unnecessary indirection and hide the object shape from IDE auto-complete.
|
||||
|
||||
```typescript
|
||||
// ✅ Correct — public readonly, assigned in constructor
|
||||
export class MyDialog extends BaseDialog {
|
||||
public readonly submitButton: Locator
|
||||
public readonly cancelButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.submitButton = this.root.getByRole('button', { name: 'Submit' })
|
||||
this.cancelButton = this.root.getByRole('button', { name: 'Cancel' })
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Avoid — getter-based locators
|
||||
export class MyDialog extends BaseDialog {
|
||||
get submitButton() {
|
||||
return this.root.getByRole('button', { name: 'Submit' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Keep as getters only when:**
|
||||
|
||||
- Lazy initialization is needed (`this._tab ??= new Tab(this.page)`)
|
||||
- The value is computed from runtime state (e.g. `get id() { return this.userIds[index] }`)
|
||||
- It's a private convenience accessor (e.g. `private get page() { return this.comfyPage.page }`)
|
||||
|
||||
When a class has cached locator properties, prefer reusing them in methods rather than rebuilding locators from scratch.
|
||||
|
||||
## Polling Assertions
|
||||
|
||||
Prefer `expect.poll()` over `expect(async () => { ... }).toPass()` when the block contains a single async call with a single assertion. `expect.poll()` is more readable and gives better error messages (shows actual vs expected on failure).
|
||||
|
||||
@@ -26,11 +26,10 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
static defaultSteps = 5
|
||||
static defaultOptions: DragOptions = { steps: ComfyMouse.defaultSteps }
|
||||
|
||||
constructor(readonly comfyPage: ComfyPage) {}
|
||||
readonly mouse: Mouse
|
||||
|
||||
/** The normal Playwright {@link Mouse} property from {@link ComfyPage.page}. */
|
||||
get mouse() {
|
||||
return this.comfyPage.page.mouse
|
||||
constructor(readonly comfyPage: ComfyPage) {
|
||||
this.mouse = comfyPage.page.mouse
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
|
||||
@@ -73,15 +73,13 @@ class ComfyMenu {
|
||||
public readonly sideToolbar: Locator
|
||||
public readonly propertiesPanel: ComfyPropertiesPanel
|
||||
public readonly modeToggleButton: Locator
|
||||
public readonly buttons: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.sideToolbar = page.getByTestId(TestIds.sidebar.toolbar)
|
||||
this.modeToggleButton = page.getByTestId(TestIds.sidebar.modeToggle)
|
||||
this.propertiesPanel = new ComfyPropertiesPanel(page)
|
||||
}
|
||||
|
||||
get buttons() {
|
||||
return this.sideToolbar.locator('.side-bar-button')
|
||||
this.buttons = this.sideToolbar.locator('.side-bar-button')
|
||||
}
|
||||
|
||||
get modelLibraryTab() {
|
||||
@@ -183,6 +181,7 @@ export class ComfyPage {
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
public readonly cloudAuth: CloudAuthHelper
|
||||
public readonly visibleToasts: Locator
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -225,6 +224,7 @@ export class ComfyPage {
|
||||
this.workflow = new WorkflowHelper(this)
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.visibleToasts = this.toast.visibleToasts
|
||||
this.dragDrop = new DragDropHelper(page)
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
@@ -237,10 +237,6 @@ export class ComfyPage {
|
||||
this.cloudAuth = new CloudAuthHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
return this.toast.visibleToasts
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export class UserSelectPage {
|
||||
public readonly selectionUrl: string
|
||||
public readonly container: Locator
|
||||
public readonly newUserInput: Locator
|
||||
public readonly existingUserSelect: Locator
|
||||
public readonly nextButton: Locator
|
||||
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
public readonly page: Page
|
||||
) {}
|
||||
|
||||
get selectionUrl() {
|
||||
return this.url + '/user-select'
|
||||
}
|
||||
|
||||
get container() {
|
||||
return this.page.locator('#comfy-user-selection')
|
||||
}
|
||||
|
||||
get newUserInput() {
|
||||
return this.container.locator('#new-user-input')
|
||||
}
|
||||
|
||||
get existingUserSelect() {
|
||||
return this.container.locator('#existing-user-select')
|
||||
}
|
||||
|
||||
get nextButton() {
|
||||
return this.container.getByText('Next')
|
||||
) {
|
||||
this.selectionUrl = url + '/user-select'
|
||||
this.container = page.locator('#comfy-user-selection')
|
||||
this.newUserInput = this.container.locator('#new-user-input')
|
||||
this.existingUserSelect = this.container.locator('#existing-user-select')
|
||||
this.nextButton = this.container.getByText('Next')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,20 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class VueNodeHelpers {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Get locator for all Vue node components in the DOM
|
||||
*/
|
||||
get nodes(): Locator {
|
||||
return this.page.locator('[data-node-id]')
|
||||
public readonly nodes: Locator
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
public readonly selectedNodes: Locator
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.nodes = page.locator('[data-node-id]')
|
||||
this.selectedNodes = page.locator(
|
||||
'[data-node-id].outline-node-component-outline'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,13 +30,6 @@ export class VueNodeHelpers {
|
||||
return this.page.locator(`[data-node-id="${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for selected Vue node components (using visual selection indicators)
|
||||
*/
|
||||
get selectedNodes(): Locator {
|
||||
return this.page.locator('[data-node-id].outline-node-component-outline')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
|
||||
@@ -3,13 +3,11 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
readonly header: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.root
|
||||
this.header = this.root
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Add node filter condition' })
|
||||
}
|
||||
@@ -41,6 +39,8 @@ export class ComfyNodeSearchFilterSelectionPanel {
|
||||
export class ComfyNodeSearchBox {
|
||||
public readonly input: Locator
|
||||
public readonly dropdown: Locator
|
||||
public readonly filterButton: Locator
|
||||
public readonly filterChips: Locator
|
||||
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
@@ -50,13 +50,15 @@ export class ComfyNodeSearchBox {
|
||||
this.dropdown = page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-list'
|
||||
)
|
||||
this.filterButton = page.locator(
|
||||
'.comfy-vue-node-search-container .filter-button'
|
||||
)
|
||||
this.filterChips = page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||
)
|
||||
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
||||
}
|
||||
|
||||
get filterButton() {
|
||||
return this.page.locator('.comfy-vue-node-search-container .filter-button')
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(
|
||||
nodeName: string,
|
||||
options?: { suggestionIndex?: number; exact?: boolean }
|
||||
@@ -78,12 +80,6 @@ export class ComfyNodeSearchBox {
|
||||
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
||||
}
|
||||
|
||||
get filterChips() {
|
||||
return this.page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||
)
|
||||
}
|
||||
|
||||
async removeFilter(index: number) {
|
||||
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
||||
}
|
||||
|
||||
@@ -2,18 +2,14 @@ import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(public readonly page: Page) {}
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
|
||||
get primeVueMenu() {
|
||||
return this.page.locator('.p-contextmenu, .p-menu')
|
||||
}
|
||||
|
||||
get litegraphMenu() {
|
||||
return this.page.locator('.litemenu')
|
||||
}
|
||||
|
||||
get menuItems() {
|
||||
return this.page.locator('.p-menuitem, .litemenu-entry')
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
|
||||
97
browser_tests/fixtures/components/OutputHistory.ts
Normal file
97
browser_tests/fixtures/components/OutputHistory.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
const ids = TestIds.outputHistory
|
||||
|
||||
export class OutputHistoryComponent {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
get outputs(): Locator {
|
||||
return this.page.getByTestId(ids.outputs)
|
||||
}
|
||||
|
||||
get welcome(): Locator {
|
||||
return this.page.getByTestId(ids.welcome)
|
||||
}
|
||||
|
||||
get outputInfo(): Locator {
|
||||
return this.page.getByTestId(ids.outputInfo)
|
||||
}
|
||||
|
||||
get activeQueue(): Locator {
|
||||
return this.page.getByTestId(ids.activeQueue)
|
||||
}
|
||||
|
||||
get queueBadge(): Locator {
|
||||
return this.page.getByTestId(ids.queueBadge)
|
||||
}
|
||||
|
||||
get inProgressItems(): Locator {
|
||||
return this.page.getByTestId(ids.inProgressItem)
|
||||
}
|
||||
|
||||
get historyItems(): Locator {
|
||||
return this.page.getByTestId(ids.historyItem)
|
||||
}
|
||||
|
||||
get skeletons(): Locator {
|
||||
return this.page.getByTestId(ids.skeleton)
|
||||
}
|
||||
|
||||
get latentPreviews(): Locator {
|
||||
return this.page.getByTestId(ids.latentPreview)
|
||||
}
|
||||
|
||||
get imageOutputs(): Locator {
|
||||
return this.page.getByTestId(ids.imageOutput)
|
||||
}
|
||||
|
||||
get videoOutputs(): Locator {
|
||||
return this.page.getByTestId(ids.videoOutput)
|
||||
}
|
||||
|
||||
/** The currently selected (checked) in-progress item. */
|
||||
get selectedInProgressItem(): Locator {
|
||||
return this.page.locator(
|
||||
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
|
||||
)
|
||||
}
|
||||
|
||||
/** The currently selected (checked) history item. */
|
||||
get selectedHistoryItem(): Locator {
|
||||
return this.page.locator(
|
||||
`[data-testid="${ids.historyItem}"][data-state="checked"]`
|
||||
)
|
||||
}
|
||||
|
||||
/** The header-level progress bar. */
|
||||
get headerProgressBar(): Locator {
|
||||
return this.page.getByTestId(ids.headerProgressBar)
|
||||
}
|
||||
|
||||
/** The in-progress item's progress bar (inside the thumbnail). */
|
||||
get itemProgressBar(): Locator {
|
||||
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
|
||||
}
|
||||
|
||||
/** Overall progress in the header bar. */
|
||||
get headerOverallProgress(): Locator {
|
||||
return this.headerProgressBar.getByTestId(ids.progressOverall)
|
||||
}
|
||||
|
||||
/** Node progress in the header bar. */
|
||||
get headerNodeProgress(): Locator {
|
||||
return this.headerProgressBar.getByTestId(ids.progressNode)
|
||||
}
|
||||
|
||||
/** Overall progress in the in-progress item bar. */
|
||||
get itemOverallProgress(): Locator {
|
||||
return this.itemProgressBar.getByTestId(ids.progressOverall)
|
||||
}
|
||||
|
||||
/** Node progress in the in-progress item bar. */
|
||||
get itemNodeProgress(): Locator {
|
||||
return this.itemProgressBar.getByTestId(ids.progressNode)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { BaseDialog } from '@e2e/fixtures/components/BaseDialog'
|
||||
|
||||
export class SettingDialog extends BaseDialog {
|
||||
public readonly searchBox: Locator
|
||||
public readonly categories: Locator
|
||||
public readonly contentArea: Locator
|
||||
|
||||
constructor(
|
||||
page: Page,
|
||||
public readonly comfyPage: ComfyPage
|
||||
) {
|
||||
super(page, TestIds.dialogs.settings)
|
||||
this.searchBox = this.root.getByPlaceholder(/Search/)
|
||||
this.categories = this.root.locator('nav').getByRole('button')
|
||||
this.contentArea = this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -36,22 +43,10 @@ export class SettingDialog extends BaseDialog {
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
get searchBox() {
|
||||
return this.root.getByPlaceholder(/Search/)
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.root.locator('nav').getByRole('button')
|
||||
}
|
||||
|
||||
category(name: string) {
|
||||
return this.root.locator('nav').getByRole('button', { name })
|
||||
}
|
||||
|
||||
get contentArea() {
|
||||
return this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
|
||||
@@ -5,18 +5,16 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly tabId: string
|
||||
) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
) {
|
||||
this.tabButton = page.locator(`.${tabId}-tab-button`)
|
||||
this.selectedTabButton = page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,28 +33,19 @@ class SidebarTab {
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
public readonly nodeLibrarySearchBoxInput: Locator
|
||||
public readonly nodeLibraryTree: Locator
|
||||
public readonly nodePreview: Locator
|
||||
public readonly tabContainer: Locator
|
||||
public readonly newFolderButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get nodeLibrarySearchBoxInput() {
|
||||
return this.page.getByPlaceholder('Search Nodes...')
|
||||
}
|
||||
|
||||
get nodeLibraryTree() {
|
||||
return this.page.getByTestId(TestIds.sidebar.nodeLibrary)
|
||||
}
|
||||
|
||||
get nodePreview() {
|
||||
return this.page.locator('.node-lib-node-preview')
|
||||
}
|
||||
|
||||
get tabContainer() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
get newFolderButton() {
|
||||
return this.tabContainer.locator('.new-folder-button')
|
||||
this.nodeLibrarySearchBoxInput = page.getByPlaceholder('Search Nodes...')
|
||||
this.nodeLibraryTree = page.getByTestId(TestIds.sidebar.nodeLibrary)
|
||||
this.nodePreview = page.locator('.node-lib-node-preview')
|
||||
this.tabContainer = page.locator('.sidebar-content-container')
|
||||
this.newFolderButton = this.tabContainer.locator('.new-folder-button')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
@@ -101,34 +90,25 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly sidebarContent: Locator
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search...')
|
||||
}
|
||||
|
||||
get sidebarContent() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
this.searchInput = page.getByPlaceholder('Search...')
|
||||
this.sidebarContent = page.locator('.sidebar-content-container')
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
return this.sidebarContent.getByRole('tab', { name, exact: true })
|
||||
}
|
||||
|
||||
get allTab() {
|
||||
return this.getTab('All')
|
||||
}
|
||||
|
||||
get blueprintsTab() {
|
||||
return this.getTab('Blueprints')
|
||||
}
|
||||
|
||||
get sortButton() {
|
||||
return this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.sidebarContent
|
||||
.getByRole('treeitem', { name: folderName })
|
||||
@@ -154,12 +134,15 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
public readonly root: Locator
|
||||
public readonly activeWorkflowLabel: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.root = page.getByTestId(TestIds.sidebar.workflows)
|
||||
this.activeWorkflowLabel = this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
@@ -168,12 +151,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
get activeWorkflowLabel(): Locator {
|
||||
return this.root.locator(
|
||||
'.comfyui-workflows-open .p-tree-node-selected .node-label'
|
||||
)
|
||||
}
|
||||
|
||||
async getActiveWorkflowName() {
|
||||
return await this.activeWorkflowLabel.innerText()
|
||||
}
|
||||
@@ -228,36 +205,27 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
public readonly searchInput: Locator
|
||||
public readonly modelTree: Locator
|
||||
public readonly refreshButton: Locator
|
||||
public readonly loadAllFoldersButton: Locator
|
||||
public readonly folderNodes: Locator
|
||||
public readonly leafNodes: Locator
|
||||
public readonly modelPreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'model-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Models...')
|
||||
}
|
||||
|
||||
get modelTree() {
|
||||
return this.page.locator('.model-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get refreshButton() {
|
||||
return this.page.getByRole('button', { name: 'Refresh' })
|
||||
}
|
||||
|
||||
get loadAllFoldersButton() {
|
||||
return this.page.getByRole('button', { name: 'Load All Folders' })
|
||||
}
|
||||
|
||||
get folderNodes() {
|
||||
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
||||
}
|
||||
|
||||
get leafNodes() {
|
||||
return this.modelTree.locator('.p-tree-node-leaf')
|
||||
}
|
||||
|
||||
get modelPreview() {
|
||||
return this.page.locator('.model-lib-model-preview')
|
||||
this.searchInput = page.getByPlaceholder('Search Models...')
|
||||
this.modelTree = page.locator('.model-lib-tree-explorer')
|
||||
this.refreshButton = page.getByRole('button', { name: 'Refresh' })
|
||||
this.loadAllFoldersButton = page.getByRole('button', {
|
||||
name: 'Load All Folders'
|
||||
})
|
||||
this.folderNodes = this.modelTree.locator(
|
||||
'.p-tree-node:not(.p-tree-node-leaf)'
|
||||
)
|
||||
this.leafNodes = this.modelTree.locator('.p-tree-node-leaf')
|
||||
this.modelPreview = page.locator('.model-lib-model-preview')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
@@ -281,137 +249,95 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
public readonly generatedTab: Locator
|
||||
public readonly importedTab: Locator
|
||||
|
||||
// --- Empty state ---
|
||||
public readonly emptyStateMessage: Locator
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
// --- Search & filter ---
|
||||
public readonly searchInput: Locator
|
||||
public readonly settingsButton: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
public readonly selectedCards: Locator
|
||||
|
||||
// --- List view items ---
|
||||
public readonly listViewItems: Locator
|
||||
|
||||
// --- Selection footer ---
|
||||
public readonly selectionFooter: Locator
|
||||
public readonly selectionCountButton: Locator
|
||||
public readonly deselectAllButton: Locator
|
||||
public readonly deleteSelectedButton: Locator
|
||||
public readonly downloadSelectedButton: Locator
|
||||
|
||||
// --- Folder view ---
|
||||
public readonly backToAssetsButton: Locator
|
||||
|
||||
// --- Loading ---
|
||||
public readonly skeletonLoaders: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
this.generatedTab = page.getByRole('tab', { name: 'Generated' })
|
||||
this.importedTab = page.getByRole('tab', { name: 'Imported' })
|
||||
this.emptyStateMessage = page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.assetCards = page.locator('[role="button"][data-selected]')
|
||||
this.selectedCards = page.locator('[data-selected="true"]')
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
)
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
return this.assetCards.filter({ hasText: name })
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
|
||||
@@ -10,6 +10,17 @@ export class SignInDialog extends BaseDialog {
|
||||
readonly apiKeyButton: Locator
|
||||
readonly termsLink: Locator
|
||||
readonly privacyLink: Locator
|
||||
readonly heading: Locator
|
||||
readonly signUpLink: Locator
|
||||
readonly signInLink: Locator
|
||||
readonly signUpEmailInput: Locator
|
||||
readonly signUpPasswordInput: Locator
|
||||
readonly signUpConfirmPasswordInput: Locator
|
||||
readonly signUpButton: Locator
|
||||
readonly apiKeyHeading: Locator
|
||||
readonly apiKeyInput: Locator
|
||||
readonly backButton: Locator
|
||||
readonly dividerText: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
@@ -22,6 +33,22 @@ export class SignInDialog extends BaseDialog {
|
||||
})
|
||||
this.termsLink = this.root.getByRole('link', { name: 'Terms of Use' })
|
||||
this.privacyLink = this.root.getByRole('link', { name: 'Privacy Policy' })
|
||||
this.heading = this.root.getByRole('heading').first()
|
||||
this.signUpLink = this.root.getByText('Sign up', { exact: true })
|
||||
this.signInLink = this.root.getByText('Sign in', { exact: true })
|
||||
this.signUpEmailInput = this.root.locator('#comfy-org-sign-up-email')
|
||||
this.signUpPasswordInput = this.root.locator('#comfy-org-sign-up-password')
|
||||
this.signUpConfirmPasswordInput = this.root.locator(
|
||||
'#comfy-org-sign-up-confirm-password'
|
||||
)
|
||||
this.signUpButton = this.root.getByRole('button', {
|
||||
name: 'Sign up',
|
||||
exact: true
|
||||
})
|
||||
this.apiKeyHeading = this.root.getByRole('heading', { name: 'API Key' })
|
||||
this.apiKeyInput = this.root.locator('#comfy-org-api-key')
|
||||
this.backButton = this.root.getByRole('button', { name: 'Back' })
|
||||
this.dividerText = this.root.getByText('Or continue with')
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -30,48 +57,4 @@ export class SignInDialog extends BaseDialog {
|
||||
})
|
||||
await this.waitForVisible()
|
||||
}
|
||||
|
||||
get heading() {
|
||||
return this.root.getByRole('heading').first()
|
||||
}
|
||||
|
||||
get signUpLink() {
|
||||
return this.root.getByText('Sign up', { exact: true })
|
||||
}
|
||||
|
||||
get signInLink() {
|
||||
return this.root.getByText('Sign in', { exact: true })
|
||||
}
|
||||
|
||||
get signUpEmailInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-email')
|
||||
}
|
||||
|
||||
get signUpPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-password')
|
||||
}
|
||||
|
||||
get signUpConfirmPasswordInput() {
|
||||
return this.root.locator('#comfy-org-sign-up-confirm-password')
|
||||
}
|
||||
|
||||
get signUpButton() {
|
||||
return this.root.getByRole('button', { name: 'Sign up', exact: true })
|
||||
}
|
||||
|
||||
get apiKeyHeading() {
|
||||
return this.root.getByRole('heading', { name: 'API Key' })
|
||||
}
|
||||
|
||||
get apiKeyInput() {
|
||||
return this.root.locator('#comfy-org-api-key')
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.root.getByRole('button', { name: 'Back' })
|
||||
}
|
||||
|
||||
get dividerText() {
|
||||
return this.root.getByText('Or continue with')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
private readonly menuTrigger: Locator
|
||||
readonly newWorkflowButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menuLocator = page.locator('.comfy-command-menu')
|
||||
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
||||
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
@@ -50,10 +52,6 @@ export class Topbar {
|
||||
return classes ? !classes.includes('invisible') : false
|
||||
}
|
||||
|
||||
get newWorkflowButton(): Locator {
|
||||
return this.page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
getWorkflowTab(tabName: string): Locator {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
|
||||
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
|
||||
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
@@ -14,14 +15,66 @@ export class AppModeHelper {
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
public readonly connectOutputPopover: Locator
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
TestIds.builder.connectOutputPopover
|
||||
)
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.locator('[data-testid="linear-widgets"]')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
)
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
@@ -93,61 +146,6 @@ export class AppModeHelper {
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
get connectOutputPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.connectOutputPopover)
|
||||
}
|
||||
|
||||
/** The empty-state placeholder shown when no outputs are selected. */
|
||||
get outputPlaceholder(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.outputPlaceholder)
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
}
|
||||
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
get imagePickerPopover(): Locator {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
}
|
||||
|
||||
/** The Run button in the app mode footer. */
|
||||
get runButton(): Locator {
|
||||
return this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
}
|
||||
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
get welcome(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.welcome)
|
||||
}
|
||||
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
get emptyWorkflowText(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.emptyWorkflow)
|
||||
}
|
||||
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
get buildAppButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
}
|
||||
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
get backToWorkflowButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.backToWorkflow)
|
||||
}
|
||||
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
get loadTemplateButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.appMode.loadTemplate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
|
||||
@@ -4,48 +4,32 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class BuilderFooterHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly nav: Locator
|
||||
public readonly exitButton: Locator
|
||||
public readonly nextButton: Locator
|
||||
public readonly backButton: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly saveGroup: Locator
|
||||
public readonly saveAsButton: Locator
|
||||
public readonly saveAsChevron: Locator
|
||||
public readonly opensAsPopover: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.nav = this.page.getByTestId(TestIds.builder.footerNav)
|
||||
this.exitButton = this.buttonByName('Exit app builder')
|
||||
this.nextButton = this.buttonByName('Next')
|
||||
this.backButton = this.buttonByName('Back')
|
||||
this.saveButton = this.page.getByTestId(TestIds.builder.saveButton)
|
||||
this.saveGroup = this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
this.saveAsButton = this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
this.saveAsChevron = this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
this.opensAsPopover = this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get nav(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.footerNav)
|
||||
}
|
||||
|
||||
get exitButton(): Locator {
|
||||
return this.buttonByName('Exit app builder')
|
||||
}
|
||||
|
||||
get nextButton(): Locator {
|
||||
return this.buttonByName('Next')
|
||||
}
|
||||
|
||||
get backButton(): Locator {
|
||||
return this.buttonByName('Back')
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveButton)
|
||||
}
|
||||
|
||||
get saveGroup(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveGroup)
|
||||
}
|
||||
|
||||
get saveAsButton(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsButton)
|
||||
}
|
||||
|
||||
get saveAsChevron(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.saveAsChevron)
|
||||
}
|
||||
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
private buttonByName(name: string): Locator {
|
||||
return this.nav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
@@ -3,73 +3,61 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderSaveAsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
public readonly dialog: Locator
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
public readonly successDialog: Locator
|
||||
public readonly title: Locator
|
||||
public readonly radioGroup: Locator
|
||||
public readonly nameInput: Locator
|
||||
public readonly saveButton: Locator
|
||||
public readonly successMessage: Locator
|
||||
public readonly viewAppButton: Locator
|
||||
public readonly closeButton: Locator
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
public readonly dismissButton: Locator
|
||||
public readonly exitBuilderButton: Locator
|
||||
public readonly overwriteDialog: Locator
|
||||
public readonly overwriteButton: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.dialog = this.page.locator('[aria-labelledby="builder-save"]')
|
||||
this.successDialog = this.page.locator(
|
||||
'[aria-labelledby="builder-save-success"]'
|
||||
)
|
||||
this.title = this.dialog.getByText('Save as')
|
||||
this.radioGroup = this.dialog.getByRole('radiogroup')
|
||||
this.nameInput = this.dialog.getByRole('textbox')
|
||||
this.saveButton = this.dialog.getByRole('button', { name: 'Save' })
|
||||
this.successMessage = this.successDialog.getByText('Successfully saved')
|
||||
this.viewAppButton = this.successDialog.getByRole('button', {
|
||||
name: 'View app'
|
||||
})
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
this.overwriteDialog = this.page.getByRole('dialog', {
|
||||
name: 'Overwrite existing file?'
|
||||
})
|
||||
this.overwriteButton = this.overwriteDialog.getByRole('button', {
|
||||
name: 'Overwrite'
|
||||
})
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
/** The save-as dialog (scoped by aria-labelledby). */
|
||||
get dialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save"]')
|
||||
}
|
||||
|
||||
/** The post-save success dialog (scoped by aria-labelledby). */
|
||||
get successDialog(): Locator {
|
||||
return this.page.locator('[aria-labelledby="builder-save-success"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.dialog.getByText('Save as')
|
||||
}
|
||||
|
||||
get radioGroup(): Locator {
|
||||
return this.dialog.getByRole('radiogroup')
|
||||
}
|
||||
|
||||
get nameInput(): Locator {
|
||||
return this.dialog.getByRole('textbox')
|
||||
}
|
||||
|
||||
viewTypeRadio(viewType: 'App' | 'Node graph'): Locator {
|
||||
return this.dialog.getByRole('radio', { name: viewType })
|
||||
}
|
||||
|
||||
get saveButton(): Locator {
|
||||
return this.dialog.getByRole('button', { name: 'Save' })
|
||||
}
|
||||
|
||||
get successMessage(): Locator {
|
||||
return this.successDialog.getByText('Successfully saved')
|
||||
}
|
||||
|
||||
get viewAppButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'View app' })
|
||||
}
|
||||
|
||||
get closeButton(): Locator {
|
||||
return this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
}
|
||||
|
||||
/** The X button to dismiss the success dialog without any action. */
|
||||
get dismissButton(): Locator {
|
||||
return this.successDialog.locator('button.p-dialog-close-button')
|
||||
}
|
||||
|
||||
get exitBuilderButton(): Locator {
|
||||
return this.successDialog.getByRole('button', { name: 'Exit builder' })
|
||||
}
|
||||
|
||||
get overwriteDialog(): Locator {
|
||||
return this.page.getByRole('dialog', { name: 'Overwrite existing file?' })
|
||||
}
|
||||
|
||||
get overwriteButton(): Locator {
|
||||
return this.overwriteDialog.getByRole('button', { name: 'Overwrite' })
|
||||
}
|
||||
|
||||
async fillAndSave(workflowName: string, viewType: 'App' | 'Node graph') {
|
||||
await this.nameInput.fill(workflowName)
|
||||
await this.viewTypeRadio(viewType).click()
|
||||
|
||||
@@ -32,7 +32,20 @@ async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
public readonly inputItems: Locator
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
public readonly inputItemTitles: Locator
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
public readonly previewWidgetLabels: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.inputItems = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
this.inputItemTitles = this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
this.previewWidgetLabels = this.page.getByTestId(
|
||||
TestIds.builder.widgetLabel
|
||||
)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
@@ -43,12 +56,9 @@ export class BuilderSelectHelper {
|
||||
* @param title The widget title shown in the IoItem.
|
||||
*/
|
||||
getInputItemMenu(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
return this.inputItems
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
@@ -150,38 +160,19 @@ export class BuilderSelectHelper {
|
||||
* Useful for asserting "Widget not visible" on disconnected inputs.
|
||||
*/
|
||||
getInputItemSubtitle(title: string): Locator {
|
||||
return this.page
|
||||
.getByTestId(TestIds.builder.ioItem)
|
||||
return this.inputItems
|
||||
.filter({
|
||||
has: this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByText(title, { exact: true })
|
||||
has: this.inputItemTitles.getByText(title, { exact: true })
|
||||
})
|
||||
.getByTestId(TestIds.builder.ioItemSubtitle)
|
||||
}
|
||||
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
get inputItems(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItem)
|
||||
}
|
||||
|
||||
/** All IoItem title locators in the inputs step sidebar. */
|
||||
get inputItemTitles(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItemTitle)
|
||||
}
|
||||
|
||||
/** All widget label locators in the preview/arrange sidebar. */
|
||||
get previewWidgetLabels(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.widgetLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an IoItem from one index to another in the inputs step.
|
||||
* Items are identified by their 0-based position among visible IoItems.
|
||||
*/
|
||||
async dragInputItem(fromIndex: number, toIndex: number) {
|
||||
const items = this.page.getByTestId(TestIds.builder.ioItem)
|
||||
await dragByIndex(items, fromIndex, toIndex)
|
||||
await dragByIndex(this.inputItems, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export class BuilderStepsHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
public readonly toolbar: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.toolbar = this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get toolbar(): Locator {
|
||||
return this.page.getByRole('navigation', { name: 'App Builder' })
|
||||
}
|
||||
|
||||
async goToInputs() {
|
||||
await this.toolbar.getByRole('button', { name: 'Inputs' }).click()
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
211
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
211
browser_tests/fixtures/helpers/ExecutionHelper.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { createMockJob } from './AssetsHelper'
|
||||
|
||||
/**
|
||||
* Helper for simulating prompt execution in e2e tests.
|
||||
*/
|
||||
export class ExecutionHelper {
|
||||
private jobCounter = 0
|
||||
private readonly completedJobs: RawJobListItem[] = []
|
||||
private readonly page: ComfyPage['page']
|
||||
private readonly command: ComfyPage['command']
|
||||
private readonly assets: ComfyPage['assets']
|
||||
|
||||
constructor(
|
||||
comfyPage: ComfyPage,
|
||||
private readonly ws: WebSocketRoute
|
||||
) {
|
||||
this.page = comfyPage.page
|
||||
this.command = comfyPage.command
|
||||
this.assets = comfyPage.assets
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
|
||||
* the synthetic job ID.
|
||||
*
|
||||
* The app receives a valid PromptResponse so storeJob() fires
|
||||
* and registers the job against the active workflow path.
|
||||
*/
|
||||
async run(): Promise<string> {
|
||||
const jobId = `test-job-${++this.jobCounter}`
|
||||
|
||||
let fulfilled!: () => void
|
||||
const prompted = new Promise<void>((r) => {
|
||||
fulfilled = r
|
||||
})
|
||||
|
||||
await this.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: jobId,
|
||||
node_errors: {}
|
||||
})
|
||||
})
|
||||
fulfilled()
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await this.command.executeCommand('Comfy.QueuePrompt')
|
||||
await prompted
|
||||
|
||||
return jobId
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a binary `b_preview_with_metadata` WS message (type 4).
|
||||
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
|
||||
*/
|
||||
latentPreview(jobId: string, nodeId: string): void {
|
||||
const metadata = JSON.stringify({
|
||||
node_id: nodeId,
|
||||
display_node_id: nodeId,
|
||||
parent_node_id: nodeId,
|
||||
real_node_id: nodeId,
|
||||
prompt_id: jobId,
|
||||
image_type: 'image/png'
|
||||
})
|
||||
const metadataBytes = new TextEncoder().encode(metadata)
|
||||
|
||||
// 1x1 red PNG
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
)
|
||||
|
||||
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
|
||||
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
|
||||
const view = new DataView(buf)
|
||||
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
|
||||
view.setUint32(4, metadataBytes.length)
|
||||
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||
|
||||
this.ws.send(Buffer.from(buf))
|
||||
}
|
||||
|
||||
/** Send `execution_start` WS event. */
|
||||
executionStart(jobId: string): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `executing` WS event to signal which node is currently running. */
|
||||
executing(jobId: string, nodeId: string | null): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { prompt_id: jobId, node: nodeId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `executed` WS event with node output. */
|
||||
executed(
|
||||
jobId: string,
|
||||
nodeId: string,
|
||||
output: Record<string, unknown>
|
||||
): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executed',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
node: nodeId,
|
||||
display_node: nodeId,
|
||||
output
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_success` WS event. */
|
||||
executionSuccess(jobId: string): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_success',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_error` WS event. */
|
||||
executionError(jobId: string, nodeId: string, message: string): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_error',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
exception_message: message,
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
*
|
||||
* Requires an {@link AssetsHelper} to be passed in the constructor.
|
||||
*/
|
||||
async completeWithHistory(
|
||||
jobId: string,
|
||||
nodeId: string,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
this.completedJobs.push(
|
||||
createMockJob({
|
||||
id: jobId,
|
||||
preview_output: {
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId,
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await this.assets.mockOutputHistory(this.completedJobs)
|
||||
this.executionSuccess(jobId)
|
||||
// Trigger queue/history refresh
|
||||
this.status(0)
|
||||
}
|
||||
|
||||
/** Send `status` WS event to update queue count. */
|
||||
status(queueRemaining: number): void {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,13 @@ import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class NodeOperationsHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
}
|
||||
|
||||
private get page() {
|
||||
return this.comfyPage.page
|
||||
@@ -155,10 +161,6 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
get promptDialogInput(): Locator {
|
||||
return this.page.locator('.p-dialog-content input[type="text"]')
|
||||
}
|
||||
|
||||
async fillPromptDialog(value: string): Promise<void> {
|
||||
await this.promptDialogInput.fill(value)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
@@ -2,14 +2,12 @@ import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ToastHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
public readonly visibleToasts: Locator
|
||||
public readonly toastErrors: Locator
|
||||
|
||||
get visibleToasts(): Locator {
|
||||
return this.page.locator('.p-toast-message:visible')
|
||||
}
|
||||
|
||||
get toastErrors(): Locator {
|
||||
return this.page.locator('.p-toast-message.p-toast-message-error')
|
||||
constructor(private readonly page: Page) {
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0): Promise<void> {
|
||||
|
||||
@@ -130,6 +130,24 @@ export const TestIds = {
|
||||
outputPlaceholder: 'builder-output-placeholder',
|
||||
connectOutputPopover: 'builder-connect-output-popover'
|
||||
},
|
||||
outputHistory: {
|
||||
outputs: 'linear-outputs',
|
||||
welcome: 'linear-welcome',
|
||||
outputInfo: 'linear-output-info',
|
||||
activeQueue: 'linear-job',
|
||||
queueBadge: 'linear-job-badge',
|
||||
inProgressItem: 'linear-in-progress-item',
|
||||
historyItem: 'linear-history-item',
|
||||
skeleton: 'linear-skeleton',
|
||||
latentPreview: 'linear-latent-preview',
|
||||
imageOutput: 'linear-image-output',
|
||||
videoOutput: 'linear-video-output',
|
||||
cancelRun: 'linear-cancel-run',
|
||||
headerProgressBar: 'linear-header-progress-bar',
|
||||
itemProgressBar: 'linear-item-progress-bar',
|
||||
progressOverall: 'linear-progress-overall',
|
||||
progressNode: 'linear-progress-node'
|
||||
},
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item',
|
||||
welcome: 'linear-welcome',
|
||||
@@ -180,6 +198,7 @@ export type TestIdValue =
|
||||
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
|
||||
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
|
||||
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
|
||||
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
|
||||
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
|
||||
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
|
||||
| Exclude<
|
||||
|
||||
@@ -4,38 +4,26 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {}
|
||||
public readonly header: Locator
|
||||
public readonly title: Locator
|
||||
public readonly titleInput: Locator
|
||||
public readonly body: Locator
|
||||
public readonly pinIndicator: Locator
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
|
||||
get header(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-header-"]')
|
||||
}
|
||||
|
||||
get title(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title"]')
|
||||
}
|
||||
|
||||
get titleInput(): Locator {
|
||||
return this.locator.locator('[data-testid="node-title-input"]')
|
||||
}
|
||||
|
||||
get body(): Locator {
|
||||
return this.locator.locator('[data-testid^="node-body-"]')
|
||||
}
|
||||
|
||||
get pinIndicator(): Locator {
|
||||
return this.locator.getByTestId(TestIds.node.pinIndicator)
|
||||
}
|
||||
|
||||
get collapseButton(): Locator {
|
||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
get collapseIcon(): Locator {
|
||||
return this.collapseButton.locator('i')
|
||||
}
|
||||
|
||||
get root(): Locator {
|
||||
return this.locator
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
this.title = locator.locator('[data-testid="node-title"]')
|
||||
this.titleInput = locator.locator('[data-testid="node-title-input"]')
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.locator(
|
||||
'[data-testid="node-collapse-button"]'
|
||||
)
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
@@ -1,53 +1,31 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
||||
getWebSocket: () => Promise<WebSocketRoute>
|
||||
}>({
|
||||
ws: [
|
||||
async ({ page }, use) => {
|
||||
// Each time a page loads, to catch navigations
|
||||
page.on('load', async () => {
|
||||
await page.evaluate(function () {
|
||||
// Create a wrapper for WebSocket that stores them globally
|
||||
// so we can look it up to trigger messages
|
||||
const store: Record<string, WebSocket> = (window.__ws__ = {})
|
||||
window.WebSocket = class extends window.WebSocket {
|
||||
constructor(
|
||||
...rest: ConstructorParameters<typeof window.WebSocket>
|
||||
) {
|
||||
super(...rest)
|
||||
store[this.url] = this
|
||||
}
|
||||
}
|
||||
getWebSocket: [
|
||||
async ({ context }, use) => {
|
||||
let latest: WebSocketRoute | undefined
|
||||
let resolve: ((ws: WebSocketRoute) => void) | undefined
|
||||
|
||||
await context.routeWebSocket(/\/ws/, (ws) => {
|
||||
const server = ws.connectToServer()
|
||||
server.onMessage((message) => {
|
||||
ws.send(message)
|
||||
})
|
||||
|
||||
latest = ws
|
||||
resolve?.(ws)
|
||||
})
|
||||
|
||||
await use({
|
||||
async trigger(data, url) {
|
||||
// Trigger a websocket event on the page
|
||||
await page.evaluate(
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
}
|
||||
const ws: WebSocket = window.__ws__![url]
|
||||
ws.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data
|
||||
})
|
||||
)
|
||||
},
|
||||
[JSON.stringify(data), url]
|
||||
)
|
||||
}
|
||||
await use(() => {
|
||||
if (latest) return Promise.resolve(latest)
|
||||
return new Promise<WebSocketRoute>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
})
|
||||
},
|
||||
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '@/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
@@ -18,8 +17,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
*/
|
||||
test('Does not auto-queue multiple changes at a time', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
|
||||
// Enable change auto-queue mode
|
||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
|
||||
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
}
|
||||
|
||||
// Trigger a status websocket message
|
||||
const triggerStatus = async (queueSize: number) => {
|
||||
await ws.trigger({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: {
|
||||
exec_info: {
|
||||
queue_remaining: queueSize
|
||||
const triggerStatus = (queueSize: number) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: {
|
||||
exec_info: {
|
||||
queue_remaining: queueSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as StatusWsMessage)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Extract the width from the queue response
|
||||
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
).toBe(1)
|
||||
|
||||
// Trigger a status update so auto-queue re-runs
|
||||
await triggerStatus(1)
|
||||
await triggerStatus(0)
|
||||
triggerStatus(1)
|
||||
triggerStatus(0)
|
||||
|
||||
// Ensure the queued width is the last queued value
|
||||
expect(
|
||||
|
||||
@@ -180,6 +180,48 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
// LGraphCanvas, not the Vue node drag handler.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
|
||||
const before = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
before,
|
||||
'Expected exactly 2 CLIPTextEncode nodes in default graph'
|
||||
).toHaveLength(2)
|
||||
|
||||
const target = before[0]
|
||||
const pos = await target.getPosition()
|
||||
const src = { x: pos.x + 16, y: pos.y + 16 }
|
||||
|
||||
await comfyPage.page.mouse.move(src.x, src.y)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(src.x + 120, src.y + 80, { steps: 20 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')).length
|
||||
)
|
||||
.toBe(3)
|
||||
expect(await target.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
|
||||
413
browser_tests/tests/outputHistory.spec.ts
Normal file
413
browser_tests/tests/outputHistory.spec.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { comfyPageFixture, comfyExpect as expect } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
// Node IDs from the default workflow (browser_tests/assets/default.json, 7 nodes)
|
||||
const SAVE_IMAGE_NODE = '9'
|
||||
const KSAMPLER_NODE = '3'
|
||||
const ALL_NODE_IDS = ['4', '6', '7', '5', KSAMPLER_NODE, '8', SAVE_IMAGE_NODE]
|
||||
|
||||
/** Queue a prompt, intercept it, and send execution_start. */
|
||||
async function startExecution(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute,
|
||||
exec?: ExecutionHelper
|
||||
) {
|
||||
exec ??= new ExecutionHelper(comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
// Allow storeJob() to complete before sending WS events
|
||||
await comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
return { exec, jobId }
|
||||
}
|
||||
|
||||
function imageOutput(...filenames: string[]) {
|
||||
return {
|
||||
images: filenames.map((filename) => ({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Output History', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Skeleton appears on execution start', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.skeletons.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Latent preview replaces skeleton', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.skeletons.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.latentPreviews.first()
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Image output replaces skeleton on executed', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('test_output.png'))
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.imageOutputs.first()
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Multiple outputs from single execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.executed(
|
||||
jobId,
|
||||
SAVE_IMAGE_NODE,
|
||||
imageOutput('output_001.png', 'output_002.png', 'output_003.png')
|
||||
)
|
||||
|
||||
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('Video output renders video element', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, {
|
||||
gifs: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.videoOutputs.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancel button sends interrupt during execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
const job: RawJobListItem = {
|
||||
id: jobId,
|
||||
status: 'in_progress',
|
||||
create_time: Date.now() / 1000,
|
||||
priority: 0
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
/\/api\/jobs\?status=in_progress/,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: [job],
|
||||
pagination: { offset: 0, limit: 200, total: 1, has_more: false }
|
||||
})
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
// Trigger queue refresh
|
||||
exec.status(1)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.appMode.cancelRunButton).toBeVisible()
|
||||
|
||||
await comfyPage.page.route('**/interrupt', (route) =>
|
||||
route.fulfill({ status: 200 })
|
||||
)
|
||||
const interruptRequest = comfyPage.page.waitForRequest('**/interrupt')
|
||||
await comfyPage.appMode.cancelRunButton.click()
|
||||
await interruptRequest
|
||||
})
|
||||
|
||||
test('Full execution lifecycle cleans up in-progress items', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
// Skeleton appears
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.skeletons.first()
|
||||
).toBeVisible()
|
||||
|
||||
// Latent preview replaces skeleton
|
||||
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.latentPreviews.first()
|
||||
).toBeVisible()
|
||||
|
||||
// Image output replaces latent
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('lifecycle_out.png'))
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.imageOutputs.first()
|
||||
).toBeVisible()
|
||||
|
||||
// Job completes with history mock - in-progress items fully resolved
|
||||
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'lifecycle_out.png')
|
||||
|
||||
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
|
||||
// Output now appears as a history item
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.historyItems.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Auto-selection follows latest in-progress item', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
// Skeleton is auto-selected
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||
).toBeVisible()
|
||||
|
||||
// First image is auto-selected
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||
'linear-image-output'
|
||||
)
|
||||
).toHaveAttribute('src', /first\.png/)
|
||||
|
||||
// Second image arrives - selection auto-follows without user click
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||
'linear-image-output'
|
||||
)
|
||||
).toHaveAttribute('src', /second\.png/)
|
||||
})
|
||||
|
||||
test('Clicking item breaks auto-follow during execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
// Send first image
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
|
||||
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
|
||||
|
||||
// Click the first image to break auto-follow
|
||||
await comfyPage.appMode.outputHistory.inProgressItems.first().click()
|
||||
|
||||
// Send second image - selection should NOT move to it
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
|
||||
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(2)
|
||||
|
||||
// The first item should still be selected (not auto-followed to second)
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem
|
||||
).toHaveCount(1)
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
|
||||
'linear-image-output'
|
||||
)
|
||||
).toHaveAttribute('src', /first\.png/)
|
||||
})
|
||||
|
||||
test('Non-output node executed events are filtered', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
// KSampler is not an output node - should be filtered
|
||||
exec.executed(jobId, KSAMPLER_NODE, imageOutput('ksampler_out.png'))
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler output should not create image outputs
|
||||
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(0)
|
||||
|
||||
// Now send from the actual output node (SaveImage)
|
||||
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('save_image_out.png'))
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.imageOutputs.first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('In-progress items are outside the scrollable area', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
|
||||
// Complete one execution with 100 image outputs
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
exec.executed(
|
||||
jobId,
|
||||
SAVE_IMAGE_NODE,
|
||||
imageOutput(
|
||||
...Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `image_${String(i).padStart(3, '0')}.png`
|
||||
)
|
||||
)
|
||||
)
|
||||
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'image_000.png')
|
||||
|
||||
await expect(comfyPage.appMode.outputHistory.historyItems).toHaveCount(100)
|
||||
|
||||
// First history item is visible before scrolling
|
||||
const firstItem = comfyPage.appMode.outputHistory.historyItems.first()
|
||||
await expect(firstItem).toBeInViewport()
|
||||
|
||||
// Scroll the history feed all the way to the right
|
||||
await comfyPage.appMode.outputHistory.outputs.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
|
||||
// First history item is now off-screen
|
||||
await expect(firstItem).not.toBeInViewport()
|
||||
|
||||
// Start a new execution to get an in-progress item
|
||||
await startExecution(comfyPage, ws, exec)
|
||||
|
||||
// In-progress item is visible despite scrolling
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeInViewport()
|
||||
})
|
||||
|
||||
test('Execution error cleans up in-progress items', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
await expect(
|
||||
comfyPage.appMode.outputHistory.inProgressItems.first()
|
||||
).toBeVisible()
|
||||
|
||||
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
|
||||
|
||||
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Progress bars update for both node and overall progress', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId } = await startExecution(comfyPage, ws)
|
||||
|
||||
const {
|
||||
inProgressItems,
|
||||
headerOverallProgress,
|
||||
headerNodeProgress,
|
||||
itemOverallProgress,
|
||||
itemNodeProgress
|
||||
} = comfyPage.appMode.outputHistory
|
||||
|
||||
await expect(inProgressItems.first()).toBeVisible()
|
||||
|
||||
// Initially both bars are at 0%
|
||||
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
|
||||
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*0%/)
|
||||
|
||||
// KSampler starts executing - node progress at 50%
|
||||
exec.executing(jobId, KSAMPLER_NODE)
|
||||
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
|
||||
|
||||
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
|
||||
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
|
||||
// Overall still 0% - no nodes completed yet
|
||||
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
|
||||
|
||||
// KSampler finishes - overall advances (1 of 7 nodes)
|
||||
exec.executed(jobId, KSAMPLER_NODE, {})
|
||||
|
||||
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
|
||||
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
|
||||
await expect(headerOverallProgress).toHaveAttribute('style', pct)
|
||||
await expect(itemOverallProgress).toHaveAttribute('style', pct)
|
||||
|
||||
// Node progress reaches 100%
|
||||
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
|
||||
|
||||
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
|
||||
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
|
||||
|
||||
// Complete remaining nodes - overall reaches 100%
|
||||
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
|
||||
for (const nodeId of remainingNodes) {
|
||||
exec.executing(jobId, nodeId)
|
||||
exec.executed(jobId, nodeId, {})
|
||||
}
|
||||
|
||||
await expect(headerOverallProgress).toHaveAttribute(
|
||||
'style',
|
||||
/width:\s*100%/
|
||||
)
|
||||
await expect(itemOverallProgress).toHaveAttribute('style', /width:\s*100%/)
|
||||
})
|
||||
})
|
||||
@@ -11,17 +11,22 @@ test.describe('Vue Node Moving', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) => {
|
||||
const loadCheckpointHeaderPos = await comfyPage.page
|
||||
.getByText('Load Checkpoint')
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
|
||||
if (!loadCheckpointHeaderPos)
|
||||
throw new Error('Load Checkpoint header not found')
|
||||
|
||||
return loadCheckpointHeaderPos
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return box
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
const expectPosChanged = async (pos1: Position, pos2: Position) => {
|
||||
const diffX = Math.abs(pos2.x - pos1.x)
|
||||
const diffY = Math.abs(pos2.y - pos1.y)
|
||||
@@ -29,6 +34,16 @@ test.describe('Vue Node Moving', () => {
|
||||
expect(diffY).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const deltaBetween = (before: Position, after: Position) => ({
|
||||
x: after.x - before.x,
|
||||
y: after.y - before.y
|
||||
})
|
||||
|
||||
const expectSameDelta = (a: Position, b: Position, tol = 2) => {
|
||||
expect(Math.abs(a.x - b.x)).toBeLessThanOrEqual(tol)
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -80,6 +95,73 @@ test.describe('Vue Node Moving', () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointBefore = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const ksamplerBefore = await getHeaderPos(comfyPage, 'KSampler')
|
||||
const latentBefore = await getHeaderPos(comfyPage, 'Empty Latent Image')
|
||||
|
||||
const dx = 120
|
||||
const dy = 80
|
||||
|
||||
const clickNodeTitleWithMeta = async (title: string) => {
|
||||
await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator('[data-testid="node-title"]')
|
||||
.first()
|
||||
.click({ modifiers: ['Meta'] })
|
||||
}
|
||||
|
||||
await comfyPage.page.keyboard.down('Meta')
|
||||
try {
|
||||
await clickNodeTitleWithMeta('Load Checkpoint')
|
||||
await clickNodeTitleWithMeta('KSampler')
|
||||
await clickNodeTitleWithMeta('Empty Latent Image')
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Meta')
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
const checkpointAfter = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const ksamplerAfter = await getHeaderPos(comfyPage, 'KSampler')
|
||||
const latentAfter = await getHeaderPos(comfyPage, 'Empty Latent Image')
|
||||
|
||||
// All three nodes should have moved together by the same delta.
|
||||
// We don't assert the exact screen delta equals the dragged pixel delta,
|
||||
// because canvas scaling and snap-to-grid can introduce offsets.
|
||||
const checkpointDelta = deltaBetween(checkpointBefore, checkpointAfter)
|
||||
const ksamplerDelta = deltaBetween(ksamplerBefore, ksamplerAfter)
|
||||
const latentDelta = deltaBetween(latentBefore, latentAfter)
|
||||
|
||||
// Confirm an actual drag happened (not zero movement).
|
||||
expect(Math.abs(checkpointDelta.x)).toBeGreaterThan(10)
|
||||
expect(Math.abs(checkpointDelta.y)).toBeGreaterThan(10)
|
||||
|
||||
// Confirm all selected nodes moved by the same delta.
|
||||
expectSameDelta(checkpointDelta, ksamplerDelta)
|
||||
expectSameDelta(checkpointDelta, latentDelta)
|
||||
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.1",
|
||||
"version": "1.44.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -8,7 +10,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
@@ -28,6 +30,9 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
persistedHeight: number | undefined
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
}
|
||||
@@ -44,6 +49,11 @@ const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
const { onPointerDown } = useAppModeWidgetResizing(
|
||||
(nodeId, widgetName, config) =>
|
||||
appModeStore.updateInputConfig(nodeId, widgetName, config)
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
@@ -61,7 +71,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
@@ -90,6 +100,9 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeId,
|
||||
widgetName,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
@@ -157,7 +170,14 @@ defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{ key, nodeData, action } in mappedSelections"
|
||||
v-for="{
|
||||
key,
|
||||
nodeId,
|
||||
widgetName,
|
||||
persistedHeight,
|
||||
nodeData,
|
||||
action
|
||||
} in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
@@ -222,8 +242,20 @@ defineExpose({ handleDragDrop })
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
:class="builderMode && 'pointer-events-none'"
|
||||
:style="
|
||||
persistedHeight
|
||||
? { '--persisted-height': `${persistedHeight}px` }
|
||||
: undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
builderMode && 'pointer-events-none',
|
||||
persistedHeight &&
|
||||
'**:data-[slot=drop-zone-indicator]:h-(--persisted-height) [&_textarea]:h-(--persisted-height)'
|
||||
)
|
||||
"
|
||||
:inert="builderMode || undefined"
|
||||
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
|
||||
210
src/components/builder/useAppModeWidgetResizing.test.ts
Normal file
210
src/components/builder/useAppModeWidgetResizing.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
|
||||
|
||||
function setHeight(el: HTMLElement, height: number) {
|
||||
Object.defineProperty(el, 'offsetHeight', {
|
||||
value: height,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
function wrapWithTextarea(initialHeight = 100): {
|
||||
wrapper: HTMLDivElement
|
||||
textarea: HTMLTextAreaElement
|
||||
} {
|
||||
const wrapper = document.createElement('div')
|
||||
const textarea = document.createElement('textarea')
|
||||
wrapper.appendChild(textarea)
|
||||
document.body.appendChild(wrapper)
|
||||
setHeight(textarea, initialHeight)
|
||||
return { wrapper, textarea }
|
||||
}
|
||||
|
||||
describe('useAppModeWidgetResizing', () => {
|
||||
function setup() {
|
||||
const onResize =
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
const { onPointerDown } = useAppModeWidgetResizing(onResize)
|
||||
|
||||
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
}
|
||||
|
||||
return { onResize, bind }
|
||||
}
|
||||
|
||||
it('persists height when textarea is resized via drag', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
})
|
||||
|
||||
it('does not persist when no height change occurs (e.g. a click)', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists once per drag gesture; stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores pointerdown on non-resizable targets (label, button, popover)', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const wrapper = document.createElement('div')
|
||||
const button = document.createElement('button')
|
||||
wrapper.appendChild(button)
|
||||
document.body.appendChild(wrapper)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists when target is a descendant of the drop-zone-indicator', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const wrapper = document.createElement('div')
|
||||
const indicator = document.createElement('div')
|
||||
indicator.setAttribute('data-slot', 'drop-zone-indicator')
|
||||
const inner = document.createElement('span')
|
||||
indicator.appendChild(inner)
|
||||
wrapper.appendChild(indicator)
|
||||
document.body.appendChild(wrapper)
|
||||
setHeight(indicator, 100)
|
||||
bind(wrapper, 1 as NodeId, 'image')
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(indicator, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
|
||||
})
|
||||
|
||||
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const first = wrapWithTextarea()
|
||||
const second = wrapWithTextarea()
|
||||
bind(first.wrapper, 1 as NodeId, 'prompt')
|
||||
bind(second.wrapper, 2 as NodeId, 'other')
|
||||
|
||||
first.textarea.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
)
|
||||
setHeight(first.textarea, 250)
|
||||
|
||||
second.textarea.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
)
|
||||
setHeight(second.textarea, 300)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
|
||||
})
|
||||
|
||||
it('treats pointercancel as the end of a gesture and persists the new height', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
})
|
||||
|
||||
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
setHeight(textarea, 400)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
})
|
||||
|
||||
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
|
||||
const onResize =
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
const scope = effectScope()
|
||||
const { onPointerDown } = scope.run(() =>
|
||||
useAppModeWidgetResizing(onResize)
|
||||
)!
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
scope.stop()
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
|
||||
expect(onResize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not match a resizable that is an ancestor of the wrapper', () => {
|
||||
const { bind, onResize } = setup()
|
||||
// An unrelated drop-zone-indicator outside the wrapper would otherwise be
|
||||
// returned by target.closest(...) walking up the tree.
|
||||
const outerIndicator = document.createElement('div')
|
||||
outerIndicator.setAttribute('data-slot', 'drop-zone-indicator')
|
||||
const wrapper = document.createElement('div')
|
||||
const inner = document.createElement('span')
|
||||
wrapper.appendChild(inner)
|
||||
outerIndicator.appendChild(wrapper)
|
||||
document.body.appendChild(outerIndicator)
|
||||
setHeight(outerIndicator, 100)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(outerIndicator, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
55
src/components/builder/useAppModeWidgetResizing.ts
Normal file
55
src/components/builder/useAppModeWidgetResizing.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
|
||||
|
||||
export function useAppModeWidgetResizing(
|
||||
onResize: (
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
config: InputWidgetConfig
|
||||
) => void
|
||||
) {
|
||||
let pendingHandler: (() => void) | null = null
|
||||
|
||||
function clearPendingHandler() {
|
||||
if (!pendingHandler) return
|
||||
window.removeEventListener('pointerup', pendingHandler)
|
||||
window.removeEventListener('pointercancel', pendingHandler)
|
||||
pendingHandler = null
|
||||
}
|
||||
|
||||
onScopeDispose(clearPendingHandler)
|
||||
|
||||
function onPointerDown(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
event: PointerEvent
|
||||
) {
|
||||
const wrapper = event.currentTarget
|
||||
const target = event.target
|
||||
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
|
||||
return
|
||||
const resizable = target.closest<HTMLElement>(RESIZABLE_SELECTOR)
|
||||
if (!resizable || !wrapper.contains(resizable)) return
|
||||
|
||||
clearPendingHandler()
|
||||
|
||||
const startHeight = resizable.offsetHeight
|
||||
const handler = () => {
|
||||
window.removeEventListener('pointerup', handler)
|
||||
window.removeEventListener('pointercancel', handler)
|
||||
pendingHandler = null
|
||||
const height = resizable.offsetHeight
|
||||
if (height === startHeight) return
|
||||
onResize(nodeId, widgetName, { height })
|
||||
}
|
||||
pendingHandler = handler
|
||||
window.addEventListener('pointerup', handler)
|
||||
window.addEventListener('pointercancel', handler)
|
||||
}
|
||||
|
||||
return { onPointerDown }
|
||||
}
|
||||
@@ -32,21 +32,14 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +108,7 @@ const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-3.5" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -33,7 +33,7 @@
|
||||
:aria-label="$t('g.edit')"
|
||||
@click.stop="editBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-3.5" />
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
|
||||
138
src/composables/useReconnectingNotification.test.ts
Normal file
138
src/composables/useReconnectingNotification.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockToastRemove = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd,
|
||||
remove: mockToastRemove
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
disableToast: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Toast.DisableReconnectingToast')
|
||||
return settingMocks.disableToast
|
||||
return undefined
|
||||
})
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useReconnectingNotification', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.disableToast = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not show toast immediately on reconnecting', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error toast after delay', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses toast when reconnected before delay expires', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(500)
|
||||
onReconnected()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes toast and shows success when reconnected after delay', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
mockToastAdd.mockClear()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'g.reconnected',
|
||||
life: 2000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when toast is disabled via setting', () => {
|
||||
settingMocks.disableToast = true
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when onReconnected is called without prior onReconnecting', () => {
|
||||
const { onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles multiple reconnecting events without duplicating toasts', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500) // first toast fires
|
||||
onReconnecting() // second reconnecting event
|
||||
vi.advanceTimersByTime(1500) // second toast fires
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
52
src/composables/useReconnectingNotification.ts
Normal file
52
src/composables/useReconnectingNotification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const RECONNECT_TOAST_DELAY_MS = 1500
|
||||
|
||||
export function useReconnectingNotification() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const reconnectingToastShown = ref(false)
|
||||
|
||||
const { start, stop } = useTimeoutFn(
|
||||
() => {
|
||||
toast.add(reconnectingMessage)
|
||||
reconnectingToastShown.value = true
|
||||
},
|
||||
RECONNECT_TOAST_DELAY_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function onReconnecting() {
|
||||
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
|
||||
start()
|
||||
}
|
||||
|
||||
function onReconnected() {
|
||||
stop()
|
||||
|
||||
if (reconnectingToastShown.value) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
reconnectingToastShown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { onReconnecting, onReconnected }
|
||||
}
|
||||
@@ -9,8 +9,14 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
export interface InputWidgetConfig {
|
||||
height?: number
|
||||
}
|
||||
|
||||
export type LinearInput = [NodeId, string, InputWidgetConfig?]
|
||||
|
||||
export interface LinearData {
|
||||
inputs: [NodeId, string][]
|
||||
inputs: LinearInput[]
|
||||
outputs: NodeId[]
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,69 @@ describe('parseComfyWorkflow', () => {
|
||||
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
|
||||
})
|
||||
|
||||
describe('linearData.inputs schema', () => {
|
||||
it('validates 2-tuple format (legacy)', async () => {
|
||||
const workflow = JSON.parse(JSON.stringify(defaultGraph))
|
||||
workflow.extra = {
|
||||
linearData: { inputs: [[1, 'prompt']], outputs: [1] }
|
||||
}
|
||||
const result = await validateComfyWorkflow(workflow)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.extra!.linearData!.inputs).toEqual([[1, 'prompt']])
|
||||
})
|
||||
|
||||
it('validates 3-tuple format with config', async () => {
|
||||
const workflow = JSON.parse(JSON.stringify(defaultGraph))
|
||||
workflow.extra = {
|
||||
linearData: { inputs: [[1, 'prompt', { height: 200 }]], outputs: [] }
|
||||
}
|
||||
const result = await validateComfyWorkflow(workflow)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.extra!.linearData!.inputs![0]).toEqual([
|
||||
1,
|
||||
'prompt',
|
||||
{ height: 200 }
|
||||
])
|
||||
})
|
||||
|
||||
it('validates 3-tuple format with empty config', async () => {
|
||||
const workflow = JSON.parse(JSON.stringify(defaultGraph))
|
||||
workflow.extra = {
|
||||
linearData: { inputs: [[1, 'prompt', {}]], outputs: [] }
|
||||
}
|
||||
const result = await validateComfyWorkflow(workflow)
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('validates mixed 2-tuple and 3-tuple entries', async () => {
|
||||
const workflow = JSON.parse(JSON.stringify(defaultGraph))
|
||||
workflow.extra = {
|
||||
linearData: {
|
||||
inputs: [
|
||||
[1, 'prompt'],
|
||||
[2, 'seed', { height: 100 }]
|
||||
],
|
||||
outputs: []
|
||||
}
|
||||
}
|
||||
const result = await validateComfyWorkflow(workflow)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.extra!.linearData!.inputs).toEqual([
|
||||
[1, 'prompt'],
|
||||
[2, 'seed', { height: 100 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects invalid config shape', async () => {
|
||||
const workflow = JSON.parse(JSON.stringify(defaultGraph))
|
||||
workflow.extra = {
|
||||
linearData: { inputs: [[1, 'prompt', 'invalid']], outputs: [] }
|
||||
}
|
||||
const result = await validateComfyWorkflow(workflow)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('workflow.nodes.pos', async () => {
|
||||
const workflow = JSON.parse(JSON.stringify(defaultGraph))
|
||||
workflow.nodes[0].pos = [1, 2, 3]
|
||||
|
||||
@@ -285,7 +285,18 @@ const zExtra = z
|
||||
linearMode: z.boolean().optional(),
|
||||
linearData: z
|
||||
.object({
|
||||
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
|
||||
inputs: z
|
||||
.array(
|
||||
z.union([
|
||||
z.tuple([
|
||||
zNodeId,
|
||||
z.string(),
|
||||
z.object({ height: z.number().optional() }).passthrough()
|
||||
]),
|
||||
z.tuple([zNodeId, z.string()])
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
outputs: z.array(zNodeId).optional()
|
||||
})
|
||||
.optional()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearInput } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -15,7 +15,7 @@ const { id, name } = defineProps<{
|
||||
const appModeStore = useAppModeStore()
|
||||
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
|
||||
|
||||
function matchesThis([nodeId, widgetName]: [NodeId, string]) {
|
||||
function matchesThis([nodeId, widgetName]: LinearInput) {
|
||||
return id == nodeId && name === widgetName
|
||||
}
|
||||
function togglePromotion() {
|
||||
|
||||
@@ -103,6 +103,7 @@ async function rerun(e: Event) {
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isWorkflowActive && !selectedItem"
|
||||
data-testid="linear-cancel-run"
|
||||
variant="destructive"
|
||||
@click="cancelActiveWorkflowJobs()"
|
||||
>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const {
|
||||
class: className,
|
||||
overallOpacity = 1,
|
||||
@@ -32,11 +30,13 @@ const executionStore = useExecutionStore()
|
||||
"
|
||||
>
|
||||
<div
|
||||
data-testid="linear-progress-overall"
|
||||
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:class="cn(rounded && 'rounded-sm')"
|
||||
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
|
||||
/>
|
||||
<div
|
||||
data-testid="linear-progress-node"
|
||||
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:class="cn(rounded && 'rounded-sm')"
|
||||
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"
|
||||
|
||||
@@ -327,6 +327,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
:key="`${item.id}-${item.state}`"
|
||||
:ref="selectedRef(`slot:${item.id}`)"
|
||||
v-bind="itemAttrs(`slot:${item.id}`)"
|
||||
data-testid="linear-in-progress-item"
|
||||
:class="itemClass"
|
||||
@click="store.select(`slot:${item.id}`)"
|
||||
>
|
||||
@@ -359,6 +360,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
:key
|
||||
:ref="selectedRef(`history:${asset.id}:${key}`)"
|
||||
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
|
||||
data-testid="linear-history-item"
|
||||
:class="itemClass"
|
||||
@click="store.select(`history:${asset.id}:${key}`)"
|
||||
>
|
||||
|
||||
@@ -58,6 +58,7 @@ function clearQueue(close: () => void) {
|
||||
<div
|
||||
v-if="queueCount > 1"
|
||||
aria-hidden="true"
|
||||
data-testid="linear-job-badge"
|
||||
class="absolute top-0 right-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-background text-xs text-text-primary"
|
||||
v-text="queueCount"
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ const { output } = defineProps<{
|
||||
<template>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
data-testid="linear-image-output"
|
||||
class="block size-10 rounded-sm bg-secondary-background object-cover"
|
||||
loading="lazy"
|
||||
width="40"
|
||||
@@ -23,6 +24,7 @@ const { output } = defineProps<{
|
||||
/>
|
||||
<template v-else-if="getMediaType(output) === 'video'">
|
||||
<video
|
||||
data-testid="linear-video-output"
|
||||
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
|
||||
preload="metadata"
|
||||
width="40"
|
||||
|
||||
@@ -9,10 +9,19 @@ const { latentPreview } = defineProps<{
|
||||
<div class="w-10">
|
||||
<img
|
||||
v-if="latentPreview"
|
||||
data-testid="linear-latent-preview"
|
||||
class="block size-10 rounded-sm object-cover"
|
||||
:src="latentPreview"
|
||||
/>
|
||||
<div v-else class="skeleton-shimmer size-10 rounded-sm" />
|
||||
<LinearProgressBar class="mt-1 h-1 w-10" rounded />
|
||||
<div
|
||||
v-else
|
||||
data-testid="linear-skeleton"
|
||||
class="skeleton-shimmer size-10 rounded-sm"
|
||||
/>
|
||||
<LinearProgressBar
|
||||
data-testid="linear-item-progress-bar"
|
||||
class="mt-1 h-1 w-10"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -246,7 +246,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
if (!isJobForActiveWorkflow(jobId)) return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
if (!sel || isFollowing.value) {
|
||||
selectedId.value = slotId
|
||||
isFollowing.value = true
|
||||
return
|
||||
|
||||
@@ -245,6 +245,19 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt']])
|
||||
})
|
||||
|
||||
it('preserves config through pruning', () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
|
||||
)
|
||||
|
||||
store.loadSelections({
|
||||
inputs: [[1, 'prompt', { height: 150 }]]
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt', { height: 150 }]])
|
||||
})
|
||||
|
||||
it('keeps inputs for existing nodes even if widget is missing', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
@@ -390,6 +403,46 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInputConfig', () => {
|
||||
it('sets config on an existing input', () => {
|
||||
store.selectedInputs.push([1, 'prompt'])
|
||||
|
||||
store.updateInputConfig(1 as NodeId, 'prompt', { height: 200 })
|
||||
|
||||
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
|
||||
})
|
||||
|
||||
it('is a no-op when entry is not found', () => {
|
||||
store.selectedInputs.push([1, 'prompt'])
|
||||
|
||||
store.updateInputConfig(99 as NodeId, 'prompt', { height: 200 })
|
||||
|
||||
expect(store.selectedInputs[0][2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('matches nodeId with loose equality', () => {
|
||||
store.selectedInputs.push(['1', 'prompt'])
|
||||
|
||||
store.updateInputConfig(1 as NodeId, 'prompt', { height: 200 })
|
||||
|
||||
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
|
||||
})
|
||||
|
||||
it('triggers linearData sync watcher', async () => {
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow()
|
||||
store.selectedInputs.push([42, 'prompt'])
|
||||
await nextTick()
|
||||
|
||||
store.updateInputConfig(42 as NodeId, 'prompt', { height: 300 })
|
||||
await nextTick()
|
||||
|
||||
expect(app.rootGraph.extra.linearData).toEqual({
|
||||
inputs: [[42, 'prompt', { height: 300 }]],
|
||||
outputs: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
it('enables Vue nodes when entering select mode with them disabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type {
|
||||
InputWidgetConfig,
|
||||
LinearData,
|
||||
LinearInput
|
||||
} from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -29,7 +33,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
|
||||
const showVueNodeSwitchPopup = ref(false)
|
||||
|
||||
const selectedInputs = ref<[NodeId, string][]>([])
|
||||
const selectedInputs = ref<LinearInput[]>([])
|
||||
const selectedOutputs = ref<NodeId[]>([])
|
||||
const hasOutputs = computed(() => !!selectedOutputs.value.length)
|
||||
const hasNodes = computed(() => {
|
||||
@@ -160,6 +164,18 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
if (index !== -1) selectedInputs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateInputConfig(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
config: InputWidgetConfig
|
||||
) {
|
||||
const entry = selectedInputs.value.find(
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
if (!entry) return
|
||||
entry[2] = { ...entry[2], ...config }
|
||||
}
|
||||
|
||||
return {
|
||||
enterBuilder,
|
||||
exitBuilder,
|
||||
@@ -171,6 +187,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
resetSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs,
|
||||
updateInputConfig,
|
||||
showVueNodeSwitchPopup
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,10 +9,9 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export const VIEWPORT_CACHE_MAX_SIZE = 32
|
||||
@@ -139,19 +138,10 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss — fit to content only if no nodes are currently visible.
|
||||
// loadGraphData may have already restored extra.ds or called fitView
|
||||
// for templates, so only intervene when the viewport is truly empty.
|
||||
// First visit — fit to content so subgraph nodes are visible
|
||||
requestAnimationFrame(() => {
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
if (!canvas.graph) return
|
||||
|
||||
const nodes = canvas.graph.nodes
|
||||
if (!nodes?.length) return
|
||||
|
||||
canvas.ds.computeVisibleArea(canvas.viewport)
|
||||
if (anyItemOverlapsRect(nodes, canvas.ds.visible_area)) return
|
||||
|
||||
if (!canvas.graph?.nodes?.length) return
|
||||
useLitegraphService().fitView()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
VIEWPORT_CACHE_MAX_SIZE
|
||||
} from '@/stores/subgraphNavigationStore'
|
||||
|
||||
const { mockSetDirty } = vi.hoisted(() => ({
|
||||
mockSetDirty: vi.fn()
|
||||
const { mockSetDirty, mockFitView } = vi.hoisted(() => ({
|
||||
mockSetDirty: vi.fn(),
|
||||
mockFitView: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
@@ -61,9 +62,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
}))
|
||||
vi.mock('@vueuse/router', () => ({ useRouteHash: vi.fn() }))
|
||||
|
||||
const { mockFitView } = vi.hoisted(() => ({
|
||||
mockFitView: vi.fn()
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ fitView: mockFitView })
|
||||
}))
|
||||
@@ -182,32 +180,43 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('calls fitView on cache miss after rAF fires', () => {
|
||||
it('calls fitView on cache miss when graph has nodes', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
// Ensure no cached entry
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
// Add a node outside the visible area so anyItemOverlapsRect returns false
|
||||
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
|
||||
mockGraph.nodes = [{ pos: [9999, 9999], size: [100, 100] }]
|
||||
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
|
||||
mockGraph._nodes = mockGraph.nodes
|
||||
|
||||
// Use the root graph ID so the stale-guard passes
|
||||
store.restoreViewport('root')
|
||||
|
||||
expect(mockFitView).not.toHaveBeenCalled()
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Simulate rAF firing — active graph still matches
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// Cleanup
|
||||
mockGraph.nodes = []
|
||||
mockGraph._nodes = []
|
||||
})
|
||||
|
||||
it('does not call fitView on cache miss when graph has no nodes', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
|
||||
mockGraph.nodes = []
|
||||
mockGraph._nodes = []
|
||||
|
||||
store.restoreViewport('root')
|
||||
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
expect(mockFitView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips fitView if active graph changed before rAF fires', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
@@ -58,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
|
||||
@@ -103,8 +102,6 @@ setupAutoQueueHandler()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -250,28 +247,7 @@ const onExecutionSuccess = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const onReconnecting = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add(reconnectingMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const onReconnected = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
|
||||
@@ -154,6 +154,7 @@ function dragDrop(e: DragEvent) {
|
||||
@drop="dragDrop"
|
||||
>
|
||||
<LinearProgressBar
|
||||
data-testid="linear-header-progress-bar"
|
||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||
/>
|
||||
<LinearPreview
|
||||
|
||||
@@ -63,7 +63,10 @@ const IS_NIGHTLY = process.env.IS_NIGHTLY === 'true'
|
||||
let GIT_COMMIT = process.env.FRONTEND_COMMIT_HASH || ''
|
||||
if (!GIT_COMMIT) {
|
||||
try {
|
||||
GIT_COMMIT = execSync('git rev-parse HEAD', { timeout: 5000 })
|
||||
GIT_COMMIT = execSync('git rev-parse HEAD', {
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user