Compare commits

...

3 Commits

Author SHA1 Message Date
Austin Mroz
145ae4d656 WIP drag and drop/mobile tests 2026-03-27 16:50:07 -07:00
Austin Mroz
0bf58aa241 Add builder selection tests 2026-03-27 09:43:14 -07:00
Austin Mroz
2787a79c9f Remove linearMode feature flag 2026-03-25 18:39:04 -07:00
11 changed files with 222 additions and 106 deletions

View File

@@ -108,6 +108,13 @@ export class AppModeHelper {
return this.page.locator('[data-testid="linear-widgets"]')
}
get centerPanel(): Locator {
return this.page.getByTestId(TestIds.linear.centerPanel)
}
get mobileView(): Locator {
return this.page.getByTestId(TestIds.linear.mobile)
}
/**
* 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").

View File

@@ -69,6 +69,13 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
linear: {
centerPanel: 'linear-center-panel',
mobile: 'linear-mobile',
mobileNavigation: 'linear-mobile-navigation',
outputInfo: 'linear-output-info',
widgetContainer: 'linear-widgets'
},
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',

View File

@@ -0,0 +1,60 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage }) => {
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.dragDrop.dragAndDropURL('/assets/images/og-image.png')
const loadImage = await comfyPage.vueNodes.getNodeLocator('12')
await expect(loadImage).toBeVisible()
await comfyPage.appMode.enterAppModeWithInputs([['12', '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('Widget Interaction', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([
['4', 'seed'],
['4', 'sampler_name'],
['6', 'text']
])
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('smoke', async ({ comfyPage }) => {
const { mobileView } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(mobileView).toBeVisible()
const navigation = comfyPage.page
.getByRole('tablist')
.filter({ hasText: 'Assets' })
const panel = comfyPage.page.getByRole('tabpanel')
await expect(navigation).toBeVisible()
const buttons = await navigation.getByRole('tab').all()
await buttons[2].click()
await expect(panel).toContainClass('left-[200vw]')
//expect
await buttons[0].dragTo(buttons[2], { steps: 5 })
await expect(panel).toContainClass('left-[100vw]')
await navigation.getByRole('tab', { name: 'Edit & Run' }).click()
const widgets = mobileView.getByTestId(TestIds.linear.widgetContainer)
await expect(widgets).toBeInViewport({ ratio: 1 })
})
})
})

View File

@@ -0,0 +1,128 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
test.describe('App mode builder selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Can independently select inputs of same name', async ({
comfyPage
}) => {
const items = comfyPage.page.getByTestId(TestIds.builder.ioItem)
await comfyPage.vueNodes.selectNodes(['6', '7'])
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.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.page.getByTestId(TestIds.builder.ioItem)
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.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.goToOutputs()
await comfyPage.nodeOps
.getNodeRefById('9')
.then((ref) => ref.centerOnNode())
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
await saveImage.click()
const items = comfyPage.page.getByTestId(TestIds.builder.ioItem)
await expect(items).toHaveCount(1)
})
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
const items = comfyPage.page.getByTestId(TestIds.builder.ioItem)
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.vueNodes
.getNodeLocator('4')
.locator('.lg-node-widget')
.click()
await expect.soft(items).toHaveCount(0)
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.goToInputs()
await expect(items).toHaveCount(0)
await comfyPage.vueNodes
.getNodeLocator('1')
.locator('.lg-node-widget')
.click({ force: true })
await comfyPage.vueNodes
.getNodeLocator('2')
.locator('.lg-node-widget')
.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.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 })
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

@@ -139,43 +139,6 @@ describe('useFeatureFlags', () => {
})
})
describe('linearToggleEnabled', () => {
it('should return true when isNightly is true', () => {
vi.mocked(distributionTypes).isNightly = true
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
})
it('should check remote config and server feature when isNightly is false', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.LINEAR_TOGGLE_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
false
)
})
it('should return false when isNightly is false and flag is disabled', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(false)
})
})
describe('dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -19,7 +19,6 @@ export enum ServerFeatureFlag {
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
@@ -84,15 +83,6 @@ export function useFeatureFlags() {
false
)
},
get linearToggleEnabled() {
if (isNightly) return true
return resolveFlag(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
remoteConfig.value.linear_toggle_enabled,
false
)
},
/**
* Whether team workspaces feature is enabled.
* IMPORTANT: Returns false until authenticated remote config is loaded.

View File

@@ -36,10 +36,6 @@ const mockSubgraphStore = vi.hoisted(() => ({
isSubgraphBlueprint: vi.fn(() => false)
}))
const mockMenuItemStore = vi.hoisted(() => ({
hasSeenLinear: false
}))
const mockAppModeStore = vi.hoisted(() => ({
enterBuilder: vi.fn(),
pruneLinearData: vi.fn(
@@ -78,10 +74,6 @@ vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => mockSubgraphStore)
}))
vi.mock('@/stores/menuItemStore', () => ({
useMenuItemStore: vi.fn(() => mockMenuItemStore)
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn(() => mockAppModeStore)
}))
@@ -116,7 +108,6 @@ describe('useWorkflowActionsMenu', () => {
vi.clearAllMocks()
mockBookmarkStore.isBookmarked.mockReturnValue(false)
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
mockAppModeStore.selectedInputs.length = 0
mockAppModeStore.selectedOutputs.length = 0
@@ -161,43 +152,13 @@ describe('useWorkflowActionsMenu', () => {
expect(labels).not.toContain('breadcrumbsMenu.deleteWorkflow')
})
it('shows app mode items when linearToggleEnabled flag is set', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
it('shows app mode items', () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('shows app mode items when user has seen linear mode', () => {
mockMenuItemStore.hasSeenLinear = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when conditions not met', () => {
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when not root', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('shows "go to workflow mode" when in linear mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {

View File

@@ -13,7 +13,6 @@ import {
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useAppModeStore } from '@/stores/appModeStore'
import type {
@@ -52,7 +51,6 @@ export function useWorkflowActionsMenu(
const bookmarkStore = useWorkflowBookmarkStore()
const commandStore = useCommandStore()
const subgraphStore = useSubgraphStore()
const menuItemStore = useMenuItemStore()
const { flags } = useFeatureFlags()
const appModeStore = useAppModeStore()
const { enterBuilder, pruneLinearData } = appModeStore
@@ -98,8 +96,6 @@ export function useWorkflowActionsMenu(
const workflowMode =
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
const isLinearMode = workflowMode === 'app'
const showAppModeItems =
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
@@ -206,7 +202,7 @@ export function useWorkflowActionsMenu(
label: t('breadcrumbsMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
command: toggleLinear,
visible: showAppModeItems && !isLinearMode,
visible: isRoot && !isLinearMode,
prependSeparator: true,
isNew: true
})
@@ -245,7 +241,7 @@ export function useWorkflowActionsMenu(
await ensureWorkflowActive(targetWorkflow.value)
enterBuilder()
},
visible: showAppModeItems,
visible: isRoot,
isNew: true
})

View File

@@ -154,7 +154,10 @@ 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"
>
@@ -191,11 +194,17 @@ 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"
>
<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"
>
<MobileError
v-if="executionErrorStore.isErrorOverlayOpen"
@@ -205,18 +214,23 @@ 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"
/>
</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

@@ -1,26 +1,16 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore'
export const useMenuItemStore = defineStore('menuItem', () => {
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const menuItems = ref<MenuItem[]>([])
const menuItemHasActiveStateChildren = ref<Record<string, boolean>>({})
const hasSeenLinear = ref(false)
whenever(
() => canvasStore.linearMode,
() => (hasSeenLinear.value = true),
{ immediate: true, once: true }
)
const registerMenuGroup = (path: string[], items: MenuItem[]) => {
let currentLevel = menuItems.value
@@ -113,7 +103,6 @@ export const useMenuItemStore = defineStore('menuItem', () => {
loadExtensionMenuCommands,
registerCoreMenuCommands,
menuItemHasActiveStateChildren,
hasSeenLinear,
commandIdToMenuItem
}
})

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"