mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-11 06:50:05 +00:00
Compare commits
15 Commits
test/cover
...
austin/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a043f809 | ||
|
|
8409f0f06b | ||
|
|
99ea4f60c9 | ||
|
|
b92b89c65a | ||
|
|
94c2c49fa5 | ||
|
|
3a6c65a68c | ||
|
|
bbcc983190 | ||
|
|
ba3e2dfe55 | ||
|
|
4a51735266 | ||
|
|
a8352964e3 | ||
|
|
6a834c706c | ||
|
|
f1a84e815e | ||
|
|
dea48a0697 | ||
|
|
53ca8e3a35 | ||
|
|
d5bcca5ebc |
@@ -8,19 +8,22 @@ import { BuilderFooterHelper } from './BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from './BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from './BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from './BuilderStepsHelper'
|
||||
import { MobileAppHelper } from './MobileAppHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly mobile: MobileAppHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.mobile = new MobileAppHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
}
|
||||
|
||||
@@ -104,6 +107,9 @@ export class AppModeHelper {
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
}
|
||||
get centerPanel(): Locator {
|
||||
return this.page.getByTestId(TestIds.linear.centerPanel)
|
||||
}
|
||||
|
||||
/** The Run button in the app mode footer. */
|
||||
get runButton(): Locator {
|
||||
|
||||
@@ -38,6 +38,10 @@ export class BuilderSelectHelper {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
get selectedItems(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.ioItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a builder IoItem (input-select sidebar).
|
||||
* @param title The widget title shown in the IoItem.
|
||||
|
||||
41
browser_tests/fixtures/helpers/MobileAppHelper.ts
Normal file
41
browser_tests/fixtures/helpers/MobileAppHelper.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
export class MobileAppHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
get view(): Locator {
|
||||
return this.page.getByTestId(TestIds.linear.mobile)
|
||||
}
|
||||
get navigation(): Locator {
|
||||
return this.page.getByRole('tablist').filter({ hasText: 'Run' })
|
||||
}
|
||||
get workflows() {
|
||||
return this.view.getByTestId(TestIds.linear.mobileWorkflows)
|
||||
}
|
||||
get actionmenu() {
|
||||
return this.view.getByTestId(TestIds.linear.mobileActionMenu)
|
||||
}
|
||||
get navigationTabs() {
|
||||
return this.navigation.getByRole('tab')
|
||||
}
|
||||
get contentPanel() {
|
||||
return this.page.getByRole('tabpanel')
|
||||
}
|
||||
|
||||
async switchWorkflow(workflowName: string) {
|
||||
await this.workflows.click()
|
||||
await this.page.getByRole('menu').getByText(workflowName).click()
|
||||
}
|
||||
async navigateTab(name: 'run' | 'outputs' | 'assets') {
|
||||
await this.navigation.getByRole('tab', { name }).click()
|
||||
}
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
|
||||
for (let i = 0; i < count; i++) await locator.tap()
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,15 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
mobile: 'linear-mobile',
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileActionMenu: 'linear-mobile-menu',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
|
||||
@@ -38,6 +38,10 @@ export class VueNodeFixture {
|
||||
return this.locator
|
||||
}
|
||||
|
||||
get widgets(): Locator {
|
||||
return this.locator.locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
return (await this.title.textContent()) ?? ''
|
||||
}
|
||||
|
||||
110
browser_tests/tests/appMode.spec.ts
Normal file
110
browser_tests/tests/appMode.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
//an app without an image input will load the workflow
|
||||
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
||||
await expect(centerPanel).not.toBeVisible()
|
||||
|
||||
//prep a load image
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png')
|
||||
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(loadImage).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
//an app with an image input will upload the image to the input
|
||||
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
||||
await expect(centerPanel).toBeVisible()
|
||||
//an app with an image input can load from a uri-source
|
||||
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png')
|
||||
await expect(centerPanel).toBeVisible()
|
||||
})
|
||||
test('Widet Interaction', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
['3', 'seed'],
|
||||
['3', 'sampler_name'],
|
||||
['6', 'text']
|
||||
])
|
||||
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
|
||||
exact: true
|
||||
})
|
||||
const { input, incrementButton, decrementButton } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seed)
|
||||
const initialValue = Number(await input.inputValue())
|
||||
|
||||
await seed.dragTo(incrementButton, { steps: 5 })
|
||||
const intermediateValue = Number(await input.inputValue())
|
||||
await expect(intermediateValue).toBeGreaterThan(initialValue)
|
||||
|
||||
await seed.dragTo(decrementButton, { steps: 5 })
|
||||
const endValue = Number(await input.inputValue())
|
||||
await expect(endValue).toBeLessThan(intermediateValue)
|
||||
|
||||
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
|
||||
exact: true
|
||||
})
|
||||
await sampler.click()
|
||||
|
||||
await comfyPage.page.getByRole('searchbox').fill('uni')
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(sampler).toHaveText('uni_pc')
|
||||
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
|
||||
await expect(mobile.view).toBeVisible()
|
||||
await expect(mobile.navigation).toBeVisible()
|
||||
|
||||
await mobile.navigateTab('assets')
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
|
||||
|
||||
const buttons = await mobile.navigationTabs.all()
|
||||
await buttons[0].dragTo(buttons[2], { steps: 5 })
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
|
||||
|
||||
await mobile.navigateTab('run')
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
|
||||
|
||||
const steps = comfyPage.page.getByRole('spinbutton')
|
||||
await expect(steps).toHaveValue('20')
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'increment' }),
|
||||
{ count: 5 }
|
||||
)
|
||||
await expect(steps).toHaveValue('25')
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'decrement' }),
|
||||
{ count: 3 }
|
||||
)
|
||||
|
||||
await expect(steps).toHaveValue('22')
|
||||
})
|
||||
|
||||
test('workflow selection', async ({ comfyPage }) => {
|
||||
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
|
||||
for (const name of widgetNames)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
|
||||
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
|
||||
|
||||
const widgets = comfyPage.appMode.linearWidgets
|
||||
await comfyPage.appMode.mobile.navigateTab('run')
|
||||
for (let i = 0; i < widgetNames.length; i++) {
|
||||
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
|
||||
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
114
browser_tests/tests/appModeBuilder.spec.ts
Normal file
114
browser_tests/tests/appModeBuilder.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { VueNodeFixture } from '../fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('App mode builder selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Can independently select inputs of same name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const items = comfyPage.appMode.select.selectedItems
|
||||
|
||||
await comfyPage.vueNodes.selectNodes(['6', '7'])
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
const prompts = await comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.locator('.lg-node-widget')
|
||||
const count = await prompts.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(prompts.nth(i)).toBeVisible()
|
||||
await prompts.nth(i).click()
|
||||
await expect(items).toHaveCount(i + 1)
|
||||
}
|
||||
})
|
||||
test('Can drag and drop inputs', async ({ comfyPage }) => {
|
||||
const items = comfyPage.appMode.select.selectedItems
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getNodeLocator('3')
|
||||
for (const widget of await ksampler.locator('.lg-node-widget').all())
|
||||
await widget.click()
|
||||
|
||||
await items.first().dragTo(items.last(), { steps: 5 })
|
||||
await expect(items.first()).toContainText('steps')
|
||||
await items.last().dragTo(items.first(), { steps: 5 })
|
||||
//dragTo doesn't cross the center point, so denoise is moved to position 2
|
||||
await expect(items.nth(1)).toContainText('denoise')
|
||||
})
|
||||
test('Can select outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToOutputs()
|
||||
|
||||
await comfyPage.nodeOps
|
||||
.getNodeRefById('9')
|
||||
.then((ref) => ref.centerOnNode())
|
||||
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
|
||||
await saveImage.click()
|
||||
|
||||
const items = comfyPage.appMode.select.selectedItems
|
||||
await expect(items).toHaveCount(1)
|
||||
})
|
||||
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
|
||||
const items = comfyPage.appMode.select.selectedItems
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.vueNodes
|
||||
.getFixtureByTitle('Load Checkpoint')
|
||||
.then((node) => node.widgets.click())
|
||||
|
||||
await expect.soft(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
for (const locator of await comfyPage.vueNodes.getNodeByTitle('Note').all())
|
||||
await new VueNodeFixture(locator).widgets.click({ force: true })
|
||||
|
||||
await expect(items).toHaveCount(0)
|
||||
})
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
|
||||
//space toggles panning mode, canvas should remain readOnly after pressing
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.header.dblclick({ force: true })
|
||||
await expect(ksampler.titleInput).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
@@ -154,20 +154,24 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
])
|
||||
</script>
|
||||
<template>
|
||||
<section class="absolute flex size-full flex-col bg-secondary-background">
|
||||
<section
|
||||
class="absolute flex size-full flex-col bg-secondary-background"
|
||||
data-testid="linear-mobile"
|
||||
>
|
||||
<header
|
||||
class="flex h-16 w-full items-center gap-3 border-b border-border-subtle bg-base-background px-4 py-3"
|
||||
>
|
||||
<DropdownMenu :entries="menuEntries" />
|
||||
<DropdownMenu
|
||||
:entries="workflowsEntries"
|
||||
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width)"
|
||||
class="max-h-[40vh] w-(--reka-dropdown-menu-content-available-width) overflow-y-auto"
|
||||
:collision-padding="20"
|
||||
>
|
||||
<template #button>
|
||||
<!--TODO: Use button here? Probably too much work to destyle-->
|
||||
<div
|
||||
class="flex h-10 grow items-center gap-2 rounded-sm bg-secondary-background p-2"
|
||||
data-testid="linear-mobile-workflows"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--panels-top-left] shrink-0 bg-primary-background"
|
||||
@@ -191,11 +195,19 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
"
|
||||
:style="{ translate }"
|
||||
>
|
||||
<div class="absolute h-full w-screen overflow-y-auto contain-size">
|
||||
<div
|
||||
class="absolute h-full w-screen overflow-y-auto contain-size"
|
||||
role="tabpanel"
|
||||
:aria-hidden="activeIndex !== 0"
|
||||
:aria-label="t(tabs[0][0])"
|
||||
>
|
||||
<LinearControls mobile @navigate-outputs="activeIndex = 1" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
|
||||
role="tabpanel"
|
||||
:aria-hidden="activeIndex !== 1"
|
||||
:aria-label="t(tabs[1][0])"
|
||||
>
|
||||
<MobileError
|
||||
v-if="executionErrorStore.isErrorOverlayOpen"
|
||||
@@ -205,18 +217,24 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
</div>
|
||||
<AssetsSidebarTab
|
||||
class="absolute top-0 left-[200vw] h-full w-screen bg-base-background"
|
||||
role="tabpanel"
|
||||
:aria-hidden="activeIndex !== 2"
|
||||
:aria-label="t(tabs[2][0])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="sliderPaneRef"
|
||||
class="flex h-22 w-full items-center justify-around gap-4 bg-secondary-background p-4"
|
||||
role="tablist"
|
||||
>
|
||||
<Button
|
||||
v-for="([label, icon], index) in tabs"
|
||||
:key="label"
|
||||
:variant="index === activeIndex ? 'secondary' : 'muted-textonly'"
|
||||
class="h-14 grow flex-col"
|
||||
role="tab"
|
||||
:aria-selected="index === activeIndex"
|
||||
@click="onClick(index)"
|
||||
>
|
||||
<div class="relative size-4">
|
||||
|
||||
@@ -149,6 +149,7 @@ function dragDrop(e: DragEvent) {
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
|
||||
Reference in New Issue
Block a user