Compare commits

...

15 Commits

Author SHA1 Message Date
Austin Mroz
42a043f809 Reload default workflow during dragdrop
linear mode wasn't initializing properly since the current workflow
didn't have a standard 'save image' or 'preview image' output
2026-04-07 16:51:52 -07:00
Austin Mroz
8409f0f06b Ensure dragdrop tests start in vue mode 2026-04-07 16:51:52 -07:00
Austin Mroz
99ea4f60c9 Flesh out widget value test 2026-04-07 16:51:52 -07:00
Austin Mroz
b92b89c65a Use labels instead of classes 2026-04-07 16:51:52 -07:00
Austin Mroz
94c2c49fa5 Update functiony type optionality 2026-04-07 16:51:52 -07:00
Austin Mroz
3a6c65a68c No clicks on mobile 2026-04-07 16:51:52 -07:00
Austin Mroz
bbcc983190 Move mobile into subHelper, reduce getByrole 2026-04-07 16:51:51 -07:00
Austin Mroz
ba3e2dfe55 More fixture migration 2026-04-07 16:51:51 -07:00
Austin Mroz
4a51735266 Review feedback 2026-04-07 16:51:51 -07:00
Austin Mroz
a8352964e3 Post rebase fixes 2026-04-07 16:51:51 -07:00
Austin Mroz
6a834c706c Further begrudged fixture migration 2026-04-07 16:51:51 -07:00
Austin Mroz
f1a84e815e Workflow switching test 2026-04-07 16:51:51 -07:00
Austin Mroz
dea48a0697 Value interaction, initial workflow interaction 2026-04-07 16:51:51 -07:00
Austin Mroz
53ca8e3a35 WIP drag and drop/mobile tests 2026-04-07 16:51:51 -07:00
Austin Mroz
d5bcca5ebc Add builder selection tests 2026-04-07 16:51:51 -07:00
9 changed files with 312 additions and 5 deletions

View File

@@ -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 {

View File

@@ -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.

View 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()
}
}

View File

@@ -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',

View File

@@ -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()) ?? ''
}

View 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()
}
})
})
})

View 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)
})
})

View File

@@ -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">

View File

@@ -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"