Compare commits

..

4 Commits

Author SHA1 Message Date
dante01yoon
1afcf2e1bb refactor(assets-e2e): address Myestery review feedback
- Add data-testid to footer delete/download buttons in AssetsSidebarTab.vue
- Use testId selectors with icon-based fallback for footer buttons
- Dismiss toasts by clicking close buttons instead of DOM removal
- Use Playwright ControlOrMeta modifier instead of process.platform check
- Keep evaluate-based click for tab switching (explained in PR comment)
2026-03-28 08:53:00 +09:00
dante01yoon
aa47be0598 refactor(assets-e2e): address CodeRabbit review feedback
- Replace all waitForTimeout() calls with state-based waits using
  locator assertions (toBeVisible, toHaveCount, toPass)
- Replace test.skip() guards with direct assertions since mock data
  is fully controlled
- Use locator wait for context menu visibility after right-click
2026-03-27 23:49:09 +09:00
dante01yoon
b4ddb07166 fix(assets-e2e): resolve toast overlay, tab switching, and popover issues
- Dismiss toast notifications via DOM removal before sidebar interactions
- Use evaluate-based click for tab switching to bypass toolbar overlay
- Fix settings popover re-open by using localStorage for grid view restore
- Use icon-based selectors for footer buttons to handle compact mode
- Use dispatchEvent for bulk context menu right-click
2026-03-27 23:45:46 +09:00
dante01yoon
bbb12e35ea test(assets-sidebar): add comprehensive E2E tests for Assets browser panel
Extend AssetsSidebarTab page object with selectors for search, view mode,
asset cards, selection footer, context menu, and folder view navigation.

Add mock data factories (createMockJob, createMockJobs, createMockImportedFiles)
to AssetsHelper for generating realistic test fixtures.

Write 30 E2E test cases across 10 categories: empty states, tab navigation,
grid/list view display, view mode toggle, search filtering, asset selection
(single/multi), context menu actions, bulk footer actions, pagination, and
settings menu visibility.
2026-03-27 23:02:07 +09:00
50 changed files with 2669 additions and 2679 deletions

View File

@@ -1,4 +1,5 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { WorkspaceStore } from '../../types/globals'
import { TestIds } from '../selectors'
@@ -174,6 +175,8 @@ export class AssetsSidebarTab extends SidebarTab {
super(page, 'assets')
}
// --- Tab navigation ---
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
@@ -182,6 +185,8 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByRole('tab', { name: 'Imported' })
}
// --- Empty state ---
get emptyStateMessage() {
return this.page.getByText(
'Upload files or generate content to see them here'
@@ -192,8 +197,173 @@ export class AssetsSidebarTab extends SidebarTab {
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
})
}
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()
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
}
/** Dismiss all visible toast notifications by clicking their close buttons. */
async dismissToasts() {
const closeButtons = this.page.locator('.p-toast-close-button')
for (const btn of await closeButtons.all()) {
await btn.click({ force: true }).catch(() => {})
}
// Wait for toast containers to animate out after close
await this.page
.locator('.p-toast')
.first()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
}
async switchToImported() {
await this.dismissToasts()
// Use evaluate click because toast overlay can intercept force clicks
// during fade-out animation (browser hit-test still routes to overlay)
await this.importedTab.evaluate((el: HTMLElement) => el.click())
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async switchToGenerated() {
await this.dismissToasts()
await this.generatedTab.evaluate((el: HTMLElement) => el.click())
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
timeout: 3000
})
}
async openSettingsMenu() {
await this.dismissToasts()
await this.settingsButton.click({ force: true })
// Wait for popover content to render
await this.listViewOption
.or(this.gridViewOption)
.first()
.waitFor({ state: 'visible', timeout: 3000 })
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })
await this.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
}
async waitForAssets(count?: number) {
if (count !== undefined) {
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
} else {
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
}
}
}

View File

@@ -142,29 +142,6 @@ export class AppModeHelper {
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/** The builder footer nav containing save/navigation buttons. */
private get builderFooterNav(): Locator {
return this.page
.getByRole('button', { name: 'Exit app builder' })
.locator('..')
}
/** Get a button in the builder footer by its accessible name. */
getFooterButton(name: string | RegExp): Locator {
return this.builderFooterNav.getByRole('button', { name })
}
/** Click the save/save-as button in the builder footer. */
async clickSave() {
await this.getFooterButton(/^Save/).first().click()
await this.comfyPage.nextFrame()
}
/** The "Opens as" popover tab above the builder footer. */
get opensAsPopover(): Locator {
return this.page.getByTestId(TestIds.builder.opensAs)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.

View File

@@ -5,6 +5,63 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
/** Factory to create a mock completed job with preview output. */
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now() / 1000
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
priority: 0,
...overrides
}
}
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now() / 1000
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60,
execution_start_time: now - i * 60,
execution_end_time: now - i * 60 + 5 + i,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
...baseOverrides
})
)
}
/** Create mock imported file names with various media types. */
export function createMockImportedFiles(count: number): string[] {
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
return Array.from(
{ length: count },
(_, i) =>
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
)
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {

View File

@@ -79,8 +79,7 @@ export const TestIds = {
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu',
opensAs: 'builder-opens-as'
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'

View File

@@ -1,118 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from './fitToView'
import { getPromotedWidgetNames } from './promotedWidgets'
/** Click the first SaveImage/PreviewImage node on the canvas. */
async function selectOutputNode(comfyPage: ComfyPage) {
const { page } = comfyPage
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
}
/** Center on a node and click its first widget to select it as input. */
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
const { page } = comfyPage
await comfyPage.canvasOps.setScale(1)
await node.centerOnNode()
const widgetRef = await node.getWidget(0)
const widgetPos = await widgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
await comfyPage.nextFrame()
}
/**
* Enter builder on the default workflow and select I/O.
*
* Loads the default workflow, optionally transforms it (e.g. convert a node
* to subgraph), then enters builder mode and selects inputs + outputs.
*
* @param comfyPage - The page fixture.
* @param getInputNode - Returns the node to click for input selection.
* Receives the KSampler node ref and can transform the graph before
* returning the target node. Defaults to using KSampler directly.
* @returns The node used for input selection.
*/
export async function setupBuilder(
comfyPage: ComfyPage,
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
): Promise<NodeReference> {
const { appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
await selectInputWidget(comfyPage, inputNode)
await appMode.goToOutputs()
await selectOutputNode(comfyPage)
return inputNode
}
/**
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
*
* Returns the subgraph node reference for further interaction.
*/
export async function setupSubgraphBuilder(
comfyPage: ComfyPage
): Promise<NodeReference> {
return setupBuilder(comfyPage, async (ksampler) => {
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
expect(promotedNames).toContain('seed')
return subgraphNode
})
}
/** Save the workflow, reopen it, and enter app mode. */
export async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}

View File

@@ -1,11 +1,89 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import {
saveAndReopenInAppMode,
setupSubgraphBuilder
} from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,251 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
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('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
// The save-as dialog should appear with filename input and view type selection
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
await expect(dialog.getByRole('textbox')).toBeVisible()
await expect(dialog.getByText('Save as')).toBeVisible()
// View type radio group should be present
const radioGroup = dialog.getByRole('radiogroup')
await expect(radioGroup).toBeVisible()
})
test('Save as dialog allows entering filename and saving', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-save-test`
const input = dialog.getByRole('textbox')
await input.fill(workflowName)
// Save button should be enabled now
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeEnabled()
await saveButton.click()
// Success dialog should appear
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
})
test('Save as dialog disables save when filename is empty', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// Clear the filename input
const input = dialog.getByRole('textbox')
await input.fill('')
// Save button should be disabled
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeDisabled()
})
test('Builder step navigation works correctly', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Should start at outputs (we ended there in setup)
// Navigate to inputs
await appMode.goToInputs()
// Back button should be disabled on first step
const backButton = appMode.getFooterButton('Back')
await expect(backButton).toBeDisabled()
// Next button should be enabled
const nextButton = appMode.getFooterButton('Next')
await expect(nextButton).toBeEnabled()
// Navigate forward
await appMode.next()
// Back button should now be enabled
await expect(backButton).toBeEnabled()
// Navigate to preview (last step)
await appMode.next()
// Next button should be disabled on last step
await expect(nextButton).toBeDisabled()
})
test('Escape key exits builder mode', async ({ comfyPage }) => {
const { page } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Verify builder toolbar is visible
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
// Press Escape
await page.keyboard.press('Escape')
await comfyPage.nextFrame()
// Builder toolbar should be gone
await expect(toolbar).not.toBeVisible()
})
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
await expect(toolbar).toBeVisible()
await appMode.exitBuilder()
await expect(toolbar).not.toBeVisible()
})
test('Save button directly saves for previously saved workflow', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-direct-save`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Now click save again — should save directly
await appMode.clickSave()
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
})
test('Split button chevron opens save-as for saved workflow', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// First save via builder save-as to make it non-temporary
await appMode.clickSave()
const saveAsDialog = page.getByRole('dialog')
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
const workflowName = `${Date.now()} builder-split-btn`
await saveAsDialog.getByRole('textbox').fill(workflowName)
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
// Dismiss the success dialog
const successDialog = page.getByRole('dialog')
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
timeout: 5000
})
await successDialog.getByText('Close', { exact: true }).click()
await comfyPage.nextFrame()
// Click the chevron dropdown trigger
const chevronButton = appMode.getFooterButton('Save as')
await chevronButton.click()
// "Save as" menu item should appear
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
await expect(menuItem).toBeVisible({ timeout: 5000 })
await menuItem.click()
// Save-as dialog should appear
const newSaveAsDialog = page.getByRole('dialog')
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
timeout: 5000
})
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
})
test('Connect output popover appears when no outputs selected', async ({
comfyPage
}) => {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
// Without selecting any outputs, click the save button
// It should trigger the connect-output popover
await appMode.clickSave()
// The popover should show a message about connecting outputs
await expect(
page.getByText('Connect an output', { exact: false })
).toBeVisible({ timeout: 5000 })
})
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
const { page, appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
await appMode.clickSave()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 5000 })
// App should be selected by default
const appRadio = dialog.getByRole('radio', { name: /App/ })
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
// Click Node graph option
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
await graphRadio.click()
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
})
})

View File

@@ -1,66 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe(
'Change Tracker - isLoadingGraph guard',
{ tag: '@workflow' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents checkState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces checkState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a checkState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
afterConfigureGraph() {
const workflow = (window.app!.extensionManager as WorkspaceStore)
.workflow.activeWorkflow
if (!workflow) throw new Error('No workflow found')
// Bypass the guard to reproduce the corruption bug:
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.checkState()
}
})
})
// Create tab 1: blank workflow (0 nodes)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to tab 0 (workflow-a).
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// switch to blank tab and back to verify no corruption
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
await tab1.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// switch again and verify no corruption
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
})
}
)

View File

@@ -1,92 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Mask Editor', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { imagePreview } = await loadImageOnNode(comfyPage)
// Hover over the image panel to reveal action buttons
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
}
)
test(
'opens mask editor from context menu',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-header')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await contextMenu.getByText('Open in Mask Editor').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await expect(dialog).toHaveScreenshot(
'mask-editor-dialog-from-context-menu.png'
)
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

View File

@@ -1,8 +1,72 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import {
createMockJob,
createMockJobs
} from '../../fixtures/helpers/AssetsHelper'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
test.describe('Assets sidebar', () => {
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const SAMPLE_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-alpha',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-beta',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2003,
preview_output: {
filename: 'portrait.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gamma',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3020,
preview_output: {
filename: 'abstract_art.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
]
const SAMPLE_IMPORTED_FILES = [
'reference_photo.png',
'background.jpg',
'audio_clip.wav'
]
// ==========================================================================
// 1. Empty states
// ==========================================================================
test.describe('Assets sidebar - empty states', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
@@ -12,19 +76,592 @@ test.describe('Assets sidebar', () => {
await comfyPage.assets.clearMocks()
})
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
await tab.importedTab.click()
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.assetCards).toHaveCount(0)
})
})
// ==========================================================================
// 2. Tab navigation
// ==========================================================================
test.describe('Assets sidebar - tab navigation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Generated tab is active by default', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
})
test('Can switch between Generated and Imported tabs', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Switch to Imported
await tab.switchToImported()
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
// Switch back to Generated
await tab.switchToGenerated()
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
})
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
// Type search in Generated tab
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
// Switch to Imported tab
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
})
// ==========================================================================
// 3. Asset display - grid view
// ==========================================================================
test.describe('Assets sidebar - grid view display', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Displays generated assets as cards in grid view', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
test('Displays imported files when switching to Imported tab', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
// Wait for imported assets to render
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
// Imported tab should show the mocked files
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})
// ==========================================================================
// 4. View mode toggle (grid <-> list)
// ==========================================================================
test.describe('Assets sidebar - view mode toggle', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
// List view items should now be visible
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
})
test('Can switch back to grid view', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
// Switch back to grid by setting localStorage and refreshing the panel
await comfyPage.page.evaluate(() => {
localStorage.setItem(
'Comfy.Assets.Sidebar.ViewMode',
JSON.stringify('grid')
)
})
// Close and reopen sidebar to pick up the localStorage change
await tab.close()
await tab.open()
await tab.waitForAssets()
// Grid cards (with data-selected attribute) should be visible again
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
})
})
// ==========================================================================
// 5. Search functionality
// ==========================================================================
test.describe('Assets sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Search input is visible', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.searchInput).toBeVisible()
})
test('Filtering assets by search query reduces displayed count', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Search for a specific filename that matches only one asset
await tab.searchInput.fill('landscape')
// Wait for filter to reduce the count
await expect(async () => {
const filteredCount = await tab.assetCards.count()
expect(filteredCount).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
})
test('Clearing search restores all assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Filter then clear
await tab.searchInput.fill('landscape')
await expect(async () => {
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
}).toPass({ timeout: 5000 })
await tab.searchInput.fill('')
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
test('Search with no matches shows empty state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.searchInput.fill('nonexistent_file_xyz')
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
})
})
// ==========================================================================
// 6. Asset selection
// ==========================================================================
test.describe('Assets sidebar - selection', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
// Should have data-selected="true"
await expect(tab.selectedCards).toHaveCount(1)
})
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
// Click first card
await cards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Ctrl+click second card
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
await expect(tab.selectedCards).toHaveCount(2)
})
test('Selection shows footer with count and actions', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
// Footer should show selection count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
})
test('Deselect all clears selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Hover over the selection count button to reveal "Deselect all"
await tab.selectionCountButton.hover()
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
// Click "Deselect all"
await tab.deselectAllButton.click()
await expect(tab.selectedCards).toHaveCount(0)
})
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
await expect(tab.selectedCards).toHaveCount(1)
// Switch to Imported tab
await tab.switchToImported()
// Switch back - selection should be cleared
await tab.switchToGenerated()
await tab.waitForAssets()
await expect(tab.selectedCards).toHaveCount(0)
})
})
// ==========================================================================
// 7. Context menu
// ==========================================================================
test.describe('Assets sidebar - context menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
// Context menu should appear with standard items
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible({ timeout: 3000 })
})
test('Context menu contains Download action for output asset', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Download')).toBeVisible()
})
test('Context menu contains Inspect action for image assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
})
test('Context menu contains Delete action for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Delete')).toBeVisible()
})
test('Context menu contains Copy job ID for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
.locator('.p-contextmenu')
.waitFor({ state: 'visible', timeout: 3000 })
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
})
test('Context menu contains workflow actions for output assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page.waitForTimeout(200)
await expect(
tab.contextMenuItem('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
})
test('Bulk context menu shows when multiple assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
// Multi-select: click first, then Ctrl/Cmd+click second
await cards.first().click({ force: true })
await cards.nth(1).click({ modifiers: ['ControlOrMeta'], force: true })
// Verify multi-selection took effect before right-clicking
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
// Right-click on a selected card via dispatchEvent to ensure contextmenu fires
await cards.first().dispatchEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible({ timeout: 3000 })
// Bulk menu should show bulk download action
await expect(tab.contextMenuItem('Download all')).toBeVisible()
})
})
// ==========================================================================
// 8. Bulk actions (footer)
// ==========================================================================
test.describe('Assets sidebar - bulk actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Footer shows download button when assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
// Download button in footer should be visible
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Footer shows delete button when output assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
// Delete button in footer should be visible
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
})
test('Selection count displays correct number', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select two assets
const cards = tab.assetCards
const cardCount = await cards.count()
expect(cardCount).toBeGreaterThanOrEqual(2)
await cards.first().click()
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
// Selection count should show the count
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
const text = await tab.selectionCountButton.textContent()
expect(text).toMatch(/Assets Selected: \d+/)
})
})
// ==========================================================================
// 9. Pagination
// ==========================================================================
test.describe('Assets sidebar - pagination', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Initially loads a batch of assets with has_more pagination', async ({
comfyPage
}) => {
// Create a large set of jobs to trigger pagination
const manyJobs = createMockJobs(30)
await comfyPage.assets.mockOutputHistory(manyJobs)
await comfyPage.setup()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Should load at least the first batch
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})
// ==========================================================================
// 10. Settings menu visibility
// ==========================================================================
test.describe('Assets sidebar - settings menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Settings menu shows view mode options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openSettingsMenu()
await expect(tab.listViewOption).toBeVisible()
await expect(tab.gridViewOption).toBeVisible()
})
})

View File

@@ -1,114 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
function hasVisibleNodeInViewport() {
const canvas = window.app!.canvas
if (!canvas?.graph?._nodes?.length) return false
const ds = canvas.ds
const cw = canvas.canvas.width / window.devicePixelRatio
const ch = canvas.canvas.height / window.devicePixelRatio
const visLeft = -ds.offset[0]
const visTop = -ds.offset[1]
const visRight = visLeft + cw / ds.scale
const visBottom = visTop + ch / ds.scale
for (const node of canvas.graph._nodes) {
const [nx, ny] = node.pos
const [nw, nh] = node.size
if (
nx + nw > visLeft &&
nx < visRight &&
ny + nh > visTop &&
ny < visBottom
)
return true
}
return false
}
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
test('first visit fits viewport to subgraph nodes (LG)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph!
const sgNode = graph._nodes.find((n) =>
'isSubgraphNode' in n
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
: false
) as unknown as { subgraph?: typeof graph } | undefined
if (!sgNode?.subgraph) throw new Error('No subgraph node')
canvas.setGraph(sgNode.subgraph)
})
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('first visit fits viewport to subgraph nodes (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await expect
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
timeout: 2000
})
.toBe(true)
})
test('viewport is restored when returning to root (Vue)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const rootViewport = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2000 }
)
.toEqual({
scale: expect.closeTo(rootViewport.scale, 2),
offset: [
expect.closeTo(rootViewport.offset[0], 0),
expect.closeTo(rootViewport.offset[1], 0)
]
})
})
})

View File

@@ -233,7 +233,6 @@
--interface-builder-mode-background: var(--color-ocean-300);
--interface-builder-mode-button-background: var(--color-ocean-600);
--interface-builder-mode-button-foreground: var(--color-white);
--interface-builder-mode-footer-background: var(--color-ocean-900);
--nav-background: var(--color-white);
@@ -377,7 +376,6 @@
--interface-builder-mode-background: var(--color-ocean-900);
--interface-builder-mode-button-background: var(--color-ocean-600);
--interface-builder-mode-button-foreground: var(--color-white);
--interface-builder-mode-footer-background: var(--color-ocean-900);
--nav-background: var(--color-charcoal-800);
@@ -521,9 +519,6 @@
--color-interface-builder-mode-button-foreground: var(
--interface-builder-mode-button-foreground
);
--color-interface-builder-mode-footer-background: var(
--interface-builder-mode-footer-background
);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);

View File

@@ -0,0 +1,63 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
<span class="inline-flex items-center gap-2">
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
<i
aria-hidden="true"
class="icon-[lucide--circle-check-big] size-4 text-green-500"
/>
</span>
</template>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppBody')
: $t('builderToolbar.defaultModeAppliedGraphBody')
}}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppPrompt')
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
}}
</p>
<template #footer>
<template v-if="appliedAsApp">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
<template v-else>
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
{{ $t('builderToolbar.exitToWorkflow') }}
</Button>
</template>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
appliedAsApp: boolean
}>()
defineEmits<{
viewApp: []
close: []
exitToWorkflow: []
}>()
</script>

View File

@@ -1,5 +1,7 @@
<template>
<div class="flex w-full min-w-116 flex-col rounded-2xl bg-base-background">
<div
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"

View File

@@ -11,11 +11,11 @@ import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockSave = vi.hoisted(() => vi.fn())
const mockSaveAs = vi.hoisted(() => vi.fn())
const mockShowDialog = vi.hoisted(() => vi.fn())
const mockState = {
mode: 'builder:inputs' as AppMode
mode: 'builder:select' as AppMode,
settingView: false
}
vi.mock('@/composables/useAppMode', () => ({
@@ -42,37 +42,10 @@ vi.mock('@/stores/dialogStore', () => ({
})
}))
const mockActiveWorkflow = ref<{
isTemporary: boolean
initialMode?: string
isModified?: boolean
changeTracker?: { checkState: () => void }
} | null>({
isTemporary: true,
initialMode: 'app'
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { extra: {} } }
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
vi.mock('./useBuilderSave', () => ({
useBuilderSave: () => ({
save: mockSave,
saveAs: mockSaveAs,
isSaving: { value: false }
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
useAppSetDefaultView: () => ({
settingView: computed(() => mockState.settingView),
showDialog: mockShowDialog
})
}))
@@ -82,17 +55,7 @@ const i18n = createI18n({
messages: {
en: {
builderMenu: { exitAppBuilder: 'Exit app builder' },
builderToolbar: {
viewApp: 'View app',
saveAs: 'Save as',
app: 'App',
nodeGraph: 'Node graph'
},
builderFooter: {
opensAsApp: 'Open as an {mode}',
opensAsGraph: 'Open as a {mode}'
},
g: { back: 'Back', next: 'Next', save: 'Save' }
g: { back: 'Back', next: 'Next' }
}
}
})
@@ -103,7 +66,7 @@ describe('BuilderFooterToolbar', () => {
vi.clearAllMocks()
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockActiveWorkflow.value = { isTemporary: true, initialMode: 'app' }
mockState.settingView = false
})
function renderComponent() {
@@ -112,11 +75,7 @@ describe('BuilderFooterToolbar', () => {
render(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: {
Button: false,
BuilderOpensAsPopover: true,
ConnectOutputPopover: { template: '<div><slot /></div>' }
}
stubs: { Button: false }
}
})
@@ -129,12 +88,18 @@ describe('BuilderFooterToolbar', () => {
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled()
})
it('enables back on the arrange step', () => {
it('enables back on the second step', () => {
mockState.mode = 'builder:arrange'
renderComponent()
expect(screen.getByRole('button', { name: /back/i })).toBeEnabled()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
renderComponent()
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled()
})
it('disables next on arrange step when no outputs', () => {
mockState.mode = 'builder:arrange'
mockHasOutputs.value = false
@@ -162,55 +127,17 @@ describe('BuilderFooterToolbar', () => {
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /next/i }))
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockShowDialog).toHaveBeenCalledOnce()
})
it('calls exitBuilder on exit button click', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /exit app builder/i }))
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
it('calls setMode app on view app click', async () => {
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: /view app/i }))
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('shows "Save as" when workflow is temporary', () => {
mockActiveWorkflow.value = { isTemporary: true }
renderComponent()
expect(screen.getByRole('button', { name: 'Save as' })).toBeDefined()
})
it('shows "Save" when workflow is saved', () => {
mockActiveWorkflow.value = { isTemporary: false }
renderComponent()
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
it('calls saveAs when workflow is temporary', async () => {
mockActiveWorkflow.value = { isTemporary: true }
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Save as' }))
expect(mockSaveAs).toHaveBeenCalledOnce()
})
it('calls save when workflow is saved and modified', async () => {
mockActiveWorkflow.value = { isTemporary: false, isModified: true }
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockSave).toHaveBeenCalledOnce()
})
it('disables save button when workflow has no unsaved changes', () => {
mockActiveWorkflow.value = { isTemporary: false, isModified: false }
renderComponent()
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('does not call save when no outputs', async () => {
mockHasOutputs.value = false
const { user } = renderComponent()
await user.click(screen.getByRole('button', { name: 'Save as' }))
expect(mockSave).not.toHaveBeenCalled()
expect(mockSaveAs).not.toHaveBeenCalled()
})
})

View File

@@ -1,160 +1,46 @@
<template>
<div
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 flex-col items-center"
<nav
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<!-- "Opens as" attachment tab -->
<BuilderOpensAsPopover
v-if="isSaved"
:is-app-mode="isAppMode"
@select="onSetDefaultView"
/>
<!-- Main toolbar -->
<nav
class="flex items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button variant="secondary" size="lg" @click="onViewApp">
{{ t('builderToolbar.viewApp') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<Button size="lg" :class="cn('w-24', disabledSaveClasses)">
{{ isSaved ? t('g.save') : t('builderToolbar.saveAs') }}
</Button>
</ConnectOutputPopover>
<ButtonGroup
v-else-if="isSaved"
class="w-24 rounded-lg bg-secondary-background has-[[data-save-chevron]:hover]:bg-secondary-background-hover"
>
<Button
size="lg"
:disabled="!isModified"
class="flex-1"
:class="isModified ? activeSaveClasses : disabledSaveClasses"
@click="save()"
>
{{ t('g.save') }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
size="lg"
:aria-label="t('builderToolbar.saveAs')"
data-save-chevron
class="w-6 rounded-l-none border-l border-border-default px-0"
>
<i
class="icon-[lucide--chevron-down] size-4"
aria-hidden="true"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="4"
class="z-1001 min-w-36 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem as-child @select="saveAs()">
<Button
variant="secondary"
size="lg"
class="w-full justify-start font-normal"
>
{{ t('builderToolbar.saveAs') }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
<Button v-else size="lg" :class="activeSaveClasses" @click="saveAs()">
{{ t('builderToolbar.saveAs') }}
</Button>
</nav>
</div>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
import BuilderOpensAsPopover from './BuilderOpensAsPopover.vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import { useBuilderSave } from './useBuilderSave'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const workflowStore = useWorkflowStore()
const { isBuilderMode, setMode } = useAppMode()
const { isBuilderMode } = useAppMode()
const { hasOutputs } = storeToRefs(appModeStore)
const {
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext
} = useBuilderSteps({
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
hasOutputs
})
const { save, saveAs } = useBuilderSave()
const isSaved = computed(
() => workflowStore.activeWorkflow?.isTemporary === false
)
const activeSaveClasses =
'bg-interface-builder-mode-button-background text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/80'
const disabledSaveClasses =
'bg-secondary-background text-muted-foreground/50 disabled:opacity-100'
const isModified = computed(
() => workflowStore.activeWorkflow?.isModified === true
)
const isAppMode = computed(
() => workflowStore.activeWorkflow?.initialMode !== 'graph'
)
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
@@ -174,14 +60,4 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
function onExitBuilder() {
appModeStore.exitBuilder()
}
function onViewApp() {
setMode('app')
}
function onSetDefaultView(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
setWorkflowDefaultView(workflow, openAsApp)
}
</script>

View File

@@ -1,84 +0,0 @@
<template>
<PopoverRoot>
<PopoverAnchor as-child>
<div
data-testid="builder-opens-as"
class="flex h-8 min-w-64 items-center justify-center gap-2 rounded-t-2xl bg-interface-builder-mode-footer-background px-4 text-sm text-interface-builder-mode-button-foreground"
>
<i :class="cn(currentModeIcon, 'size-4')" aria-hidden="true" />
<i18n-t
:keypath="
isAppMode
? 'builderFooter.opensAsApp'
: 'builderFooter.opensAsGraph'
"
tag="span"
>
<template #mode>
<PopoverTrigger as-child>
<Button
class="-ml-0.5 h-6 gap-1 rounded-md border-none bg-transparent px-1.5 text-sm text-interface-builder-mode-button-foreground hover:bg-interface-builder-mode-button-background/70"
>
{{
isAppMode
? t('builderToolbar.app').toLowerCase()
: t('builderToolbar.nodeGraph').toLowerCase()
}}
<i
class="icon-[lucide--chevron-down] size-3.5"
aria-hidden="true"
/>
</Button>
</PopoverTrigger>
</template>
</i18n-t>
<PopoverPortal>
<PopoverContent
side="top"
:side-offset="5"
:collision-padding="10"
class="z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<ViewTypeRadioGroup
:model-value="isAppMode"
:aria-label="t('builderToolbar.defaultViewLabel')"
size="sm"
@update:model-value="$emit('select', $event)"
/>
</PopoverContent>
</PopoverPortal>
</div>
</PopoverAnchor>
</PopoverRoot>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
const { isAppMode } = defineProps<{
isAppMode: boolean
}>()
defineEmits<{
select: [openAsApp: boolean]
}>()
const { t } = useI18n()
const currentModeIcon = computed(() =>
isAppMode ? 'icon-[lucide--app-window]' : 'icon-[comfy--workflow]'
)
</script>

View File

@@ -1,71 +0,0 @@
<template>
<BuilderDialog @close="emit('close')">
<template #title>
{{ $t('builderToolbar.saveAs') }}
</template>
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="focus-visible:ring-ring flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground"
@keydown.enter="
filename.trim() && emit('save', filename.trim(), openAsApp)
"
/>
</div>
<div class="flex flex-col gap-2">
<label :id="radioGroupLabelId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
<ViewTypeRadioGroup
v-model="openAsApp"
:aria-labelledby="radioGroupLabelId"
/>
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="emit('save', filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref, useId } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
import ViewTypeRadioGroup from './ViewTypeRadioGroup.vue'
const { defaultFilename, defaultOpenAsApp = true } = defineProps<{
defaultFilename: string
defaultOpenAsApp?: boolean
}>()
const emit = defineEmits<{
save: [filename: string, openAsApp: boolean]
close: []
}>()
const inputId = useId()
const radioGroupLabelId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(defaultOpenAsApp)
</script>

View File

@@ -23,21 +23,55 @@
<StepLabel :step />
</button>
<div
v-if="index < steps.length - 1"
class="mx-1 h-px w-4 bg-border-default"
role="separator"
/>
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</ConnectOutputPopover>
<button
v-else
:class="
cn(
stepClasses,
activeStep === 'setDefaultView'
? 'bg-interface-builder-mode-background'
: 'bg-transparent hover:bg-secondary-background'
)
"
@click="navigateToStep('setDefaultView')"
>
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
@@ -45,7 +79,9 @@ import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const { activeStep, navigateToStep } = useBuilderSteps()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
@@ -71,5 +107,11 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
icon: 'icon-[lucide--layout-panel-left]'
}
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -5,8 +5,7 @@
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
:side-offset="18"
:side-offset="8"
:collision-padding="10"
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-80 rounded-xl border border-border-default bg-base-background shadow-interface will-change-[transform,opacity]"
>

View File

@@ -0,0 +1,97 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
{{ $t('builderToolbar.defaultViewTitle') }}
</template>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in viewTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
:class="
cn(
itemClasses,
openAsApp === option.value && 'bg-secondary-background'
)
"
variant="textonly"
@click="openAsApp = option.value"
>
<div
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
>
<i :class="cn(option.icon, 'size-4')" />
</div>
<div class="mx-2 flex flex-1 flex-col items-start">
<span class="text-sm font-medium text-base-foreground">
{{ option.title }}
</span>
<span class="text-xs text-muted-foreground">
{{ option.subtitle }}
</span>
</div>
<i
v-if="openAsApp === option.value"
class="icon-[lucide--check] size-4 text-base-foreground"
/>
</Button>
</div>
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.cancel') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
{{ $t('g.apply') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { initialOpenAsApp = true } = defineProps<{
initialOpenAsApp?: boolean
}>()
defineEmits<{
apply: [openAsApp: boolean]
close: []
}>()
const openAsApp = ref(initialOpenAsApp)
const viewTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',
title: t('builderToolbar.app'),
subtitle: t('builderToolbar.appDescription')
},
{
value: false,
icon: 'icon-[comfy--workflow]',
title: t('builderToolbar.nodeGraph'),
subtitle: t('builderToolbar.nodeGraphDescription')
}
]
const itemClasses =
'flex h-14 cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background'
</script>

View File

@@ -1,74 +0,0 @@
<template>
<div role="radiogroup" v-bind="$attrs" :class="cn('flex flex-col', gapClass)">
<Button
v-for="option in viewTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="modelValue === option.value"
:class="
cn(
'flex cursor-pointer items-center gap-2 self-stretch rounded-lg border-none bg-transparent py-2 pr-4 pl-2 text-base-foreground transition-colors hover:bg-secondary-background',
heightClass,
modelValue === option.value && 'bg-secondary-background'
)
"
variant="textonly"
@click="
modelValue !== option.value && emit('update:modelValue', option.value)
"
>
<div
class="flex size-8 min-h-8 items-center justify-center rounded-lg bg-secondary-background-hover"
>
<i :class="cn(option.icon, 'size-4')" aria-hidden="true" />
</div>
<div class="mx-2 flex flex-1 flex-col items-start">
<span class="text-sm font-medium text-base-foreground">
{{ option.title }}
</span>
<span class="text-xs text-muted-foreground">
{{ option.subtitle }}
</span>
</div>
<i
v-if="modelValue === option.value"
class="icon-[lucide--check] size-4 text-base-foreground"
aria-hidden="true"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { size = 'md' } = defineProps<{
modelValue: boolean
size?: 'sm' | 'md'
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const viewTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',
title: t('builderToolbar.app'),
subtitle: t('builderToolbar.appDescription')
},
{
value: false,
icon: 'icon-[comfy--workflow]',
title: t('builderToolbar.nodeGraph'),
subtitle: t('builderToolbar.nodeGraphDescription')
}
]
const heightClass = size === 'sm' ? 'h-12' : 'h-14'
const gapClass = size === 'sm' ? 'gap-1' : 'gap-2'
</script>

View File

@@ -1,70 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockLoadedWorkflow } from '@/utils/__tests__/litegraphTestUtils'
import type { setWorkflowDefaultView as SetWorkflowDefaultViewFn } from './builderViewOptions'
const mockTrackDefaultViewSet = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackDefaultViewSet: mockTrackDefaultViewSet })
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { extra: {} } }
}))
describe('setWorkflowDefaultView', () => {
let setWorkflowDefaultView: typeof SetWorkflowDefaultViewFn
let app: { rootGraph: { extra: Record<string, unknown> } }
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('./builderViewOptions')
setWorkflowDefaultView = mod.setWorkflowDefaultView
app = (await import('@/scripts/app')).app as typeof app
app.rootGraph.extra = {}
})
it('sets initialMode to app when openAsApp is true', () => {
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
setWorkflowDefaultView(workflow, true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = createMockLoadedWorkflow({ initialMode: undefined })
setWorkflowDefaultView(workflow, false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(app.rootGraph.extra.linearMode).toBe(true)
setWorkflowDefaultView(workflow, false)
expect(app.rootGraph.extra.linearMode).toBe(false)
})
it('calls changeTracker.checkState', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
})
it('tracks telemetry with correct default_view', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
default_view: 'app'
})
setWorkflowDefaultView(workflow, false)
expect(mockTrackDefaultViewSet).toHaveBeenCalledWith({
default_view: 'graph'
})
})
})

View File

@@ -1,16 +0,0 @@
import { useTelemetry } from '@/platform/telemetry'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
export function setWorkflowDefaultView(
workflow: LoadedComfyWorkflow,
openAsApp: boolean
) {
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
}

View File

@@ -0,0 +1,240 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn(),
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as {
initialMode?: string | null
changeTracker?: { checkState: () => void }
} | null
}))
const mockApp = vi.hoisted(() => ({
rootGraph: { extra: {} as Record<string, unknown> }
}))
const mockSetMode = vi.hoisted(() => vi.fn())
const mockAppModeStore = vi.hoisted(() => ({
exitBuilder: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => mockAppModeStore
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
}))
import { useAppSetDefaultView } from './useAppSetDefaultView'
describe('useAppSetDefaultView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.activeWorkflow = null
mockApp.rootGraph.extra = {}
})
describe('settingView', () => {
it('reflects dialogStore.isDialogOpen', () => {
mockDialogStore.isDialogOpen.mockReturnValue(true)
const { settingView } = useAppSetDefaultView()
expect(settingView.value).toBe(true)
})
})
describe('showDialog', () => {
it('opens dialog via dialogService', () => {
const { showDialog } = useAppSetDefaultView()
showDialog()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
})
it('passes initialOpenAsApp true when initialMode is not graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
it('passes initialOpenAsApp false when initialMode is graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(false)
})
it('passes initialOpenAsApp true when no active workflow', () => {
mockWorkflowStore.activeWorkflow = null
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
})
describe('handleApply', () => {
it('sets initialMode to app when openAsApp is true', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
})
it('closes dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view'
})
})
it('shows confirmation dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.key).toBe('builder-default-view-applied')
expect(confirmCall.props.appliedAsApp).toBe(true)
})
it('passes appliedAsApp false to confirmation dialog when graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.props.appliedAsApp).toBe(false)
})
})
describe('applied dialog', () => {
function applyAndGetConfirmDialog(openAsApp: boolean) {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
applyCall.props.onApply(openAsApp)
return mockDialogService.showLayoutDialog.mock.calls[1][0]
}
it('onViewApp sets mode to app and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onViewApp()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onExitToWorkflow exits builder and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onExitToWorkflow()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
mockDialogStore.closeDialog.mockClear()
confirmCall.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
})
})
})

View File

@@ -0,0 +1,82 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
import { useAppModeStore } from '@/stores/appModeStore'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
function showDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DefaultViewDialogContent,
props: {
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
onApply: handleApply,
onClose: closeDialog
}
})
}
function handleApply(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}
function showAppliedDialog(appliedAsApp: boolean) {
dialogService.showLayoutDialog({
key: APPLIED_DIALOG_KEY,
component: BuilderDefaultModeAppliedDialogContent,
props: {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {
closeAppliedDialog()
appModeStore.exitBuilder()
},
onClose: closeAppliedDialog
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function closeAppliedDialog() {
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
}
return { settingView, showDialog }
}

View File

@@ -1,337 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useBuilderSave } from './useBuilderSave'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockTrackEnterLinear = vi.hoisted(() => vi.fn())
const mockSaveWorkflow = vi.hoisted(() => vi.fn<() => Promise<void>>())
const mockSaveWorkflowAs = vi.hoisted(() =>
vi.fn<() => Promise<boolean | null>>()
)
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
const mockShowConfirmDialog = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockSetWorkflowDefaultView = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockActiveWorkflow = ref<{
filename: string
initialMode?: string | null
} | null>(null)
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({ toastErrorHandler: mockToastErrorHandler })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEnterLinear: mockTrackEnterLinear })
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
saveWorkflow: mockSaveWorkflow,
saveWorkflowAs: mockSaveWorkflowAs
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ showLayoutDialog: mockShowLayoutDialog })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({ exitBuilder: mockExitBuilder })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ closeDialog: mockCloseDialog })
}))
vi.mock('./builderViewOptions', () => ({
setWorkflowDefaultView: mockSetWorkflowDefaultView
}))
vi.mock('@/components/dialog/confirm/confirmDialog', () => ({
showConfirmDialog: mockShowConfirmDialog
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) => {
if (params) return `${key}:${JSON.stringify(params)}`
return key
}
}))
vi.mock('./BuilderSaveDialogContent.vue', () => ({
default: { template: '<div />' }
}))
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
describe('useBuilderSave', () => {
beforeEach(() => {
vi.clearAllMocks()
mockActiveWorkflow.value = null
})
describe('save()', () => {
it('does nothing when there is no active workflow', async () => {
const { save } = useBuilderSave()
await save()
expect(mockSaveWorkflow).not.toHaveBeenCalled()
})
it('saves workflow directly without showing a dialog', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
mockSaveWorkflow.mockResolvedValueOnce(undefined)
const { save } = useBuilderSave()
await save()
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
})
it('toasts error on failure', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const error = new Error('save failed')
mockSaveWorkflow.mockRejectedValueOnce(error)
const { save } = useBuilderSave()
await save()
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
expect(mockShowConfirmDialog).not.toHaveBeenCalled()
})
it('prevents concurrent saves', async () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
let resolveSave!: () => void
mockSaveWorkflow.mockReturnValueOnce(
new Promise<void>((r) => {
resolveSave = r
})
)
const { save, isSaving } = useBuilderSave()
const firstSave = save()
expect(isSaving.value).toBe(true)
await save()
expect(mockSaveWorkflow).toHaveBeenCalledOnce()
resolveSave()
await firstSave
expect(isSaving.value).toBe(false)
})
})
describe('saveAs()', () => {
it('does nothing when there is no active workflow', () => {
mockActiveWorkflow.value = null
const { saveAs } = useBuilderSave()
saveAs()
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
})
it('opens save dialog with correct defaultFilename and defaultOpenAsApp', () => {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { saveAs } = useBuilderSave()
saveAs()
expect(mockShowLayoutDialog).toHaveBeenCalledOnce()
const { key, props } = mockShowLayoutDialog.mock.calls[0][0]
expect(key).toBe(SAVE_DIALOG_KEY)
expect(props.defaultFilename).toBe('my-workflow')
expect(props.defaultOpenAsApp).toBe(true)
})
it('passes defaultOpenAsApp: false when initialMode is graph', () => {
mockActiveWorkflow.value = {
filename: 'my-workflow',
initialMode: 'graph'
}
const { saveAs } = useBuilderSave()
saveAs()
const { props } = mockShowLayoutDialog.mock.calls[0][0]
expect(props.defaultOpenAsApp).toBe(false)
})
})
describe('save dialog callbacks', () => {
function getSaveDialogProps() {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
const { saveAs } = useBuilderSave()
saveAs()
return mockShowLayoutDialog.mock.calls[0][0].props as {
onSave: (filename: string, openAsApp: boolean) => Promise<void>
onClose: () => void
}
}
it('onSave calls saveWorkflowAs then setWorkflowDefaultView on success', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockSaveWorkflowAs).toHaveBeenCalledWith(
mockActiveWorkflow.value,
{
filename: 'new-name'
}
)
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(
mockActiveWorkflow.value,
true
)
})
it('onSave uses fresh activeWorkflow reference for setWorkflowDefaultView', async () => {
const newWorkflow = { filename: 'new-name', initialMode: 'app' }
mockSaveWorkflowAs.mockImplementationOnce(async () => {
mockActiveWorkflow.value = newWorkflow
return true
})
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockSetWorkflowDefaultView).toHaveBeenCalledWith(newWorkflow, true)
})
it('onSave does not mutate or close when saveWorkflowAs returns falsy', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(null)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
expect(mockSetWorkflowDefaultView).not.toHaveBeenCalled()
expect(mockCloseDialog).not.toHaveBeenCalled()
})
it('onSave closes dialog and shows success dialog after successful save', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
expect(mockShowConfirmDialog).toHaveBeenCalledOnce()
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.key).toBe(SUCCESS_DIALOG_KEY)
})
it('shows app success message when openAsApp is true', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', true)
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.props.promptText).toBe('builderSave.successBodyApp')
})
it('shows graph success message with exit builder button when openAsApp is false', async () => {
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
const successCall = mockShowConfirmDialog.mock.calls[0][0]
expect(successCall.props.promptText).toBe('builderSave.successBodyGraph')
expect(successCall.footerProps.confirmText).toBe(
'linearMode.builder.exit'
)
expect(successCall.footerProps.cancelText).toBe('builderToolbar.viewApp')
})
it('onSave toasts error and closes dialog on failure', async () => {
const error = new Error('save-as failed')
mockSaveWorkflowAs.mockRejectedValueOnce(error)
const { onSave } = getSaveDialogProps()
await onSave('new-name', false)
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SAVE_DIALOG_KEY })
})
it('prevents concurrent handleSaveAs calls', async () => {
let resolveSaveAs!: (v: boolean) => void
mockSaveWorkflowAs.mockReturnValueOnce(
new Promise<boolean>((r) => {
resolveSaveAs = r
})
)
const { onSave } = getSaveDialogProps()
const firstSave = onSave('new-name', true)
await onSave('other-name', true)
expect(mockSaveWorkflowAs).toHaveBeenCalledOnce()
resolveSaveAs(true)
await firstSave
})
})
describe('graph success dialog callbacks', () => {
async function getGraphSuccessDialogProps() {
mockActiveWorkflow.value = { filename: 'my-workflow', initialMode: 'app' }
mockSaveWorkflowAs.mockResolvedValueOnce(true)
const { saveAs } = useBuilderSave()
saveAs()
const { onSave } = mockShowLayoutDialog.mock.calls[0][0].props as {
onSave: (filename: string, openAsApp: boolean) => Promise<void>
}
await onSave('new-name', false)
return mockShowConfirmDialog.mock.calls[0][0].footerProps as {
onConfirm: () => void
onCancel: () => void
}
}
it('onConfirm closes dialog and exits builder', async () => {
const { onConfirm } = await getGraphSuccessDialogProps()
onConfirm()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
it('onCancel closes dialog and switches to app mode', async () => {
const { onCancel } = await getGraphSuccessDialogProps()
onCancel()
expect(mockCloseDialog).toHaveBeenCalledWith({ key: SUCCESS_DIALOG_KEY })
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
source: 'app_builder'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
})
})

View File

@@ -1,135 +0,0 @@
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ref } from 'vue'
import { setWorkflowDefaultView } from './builderViewOptions'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
const isSaving = ref(false)
export function useBuilderSave() {
const { toastErrorHandler } = useErrorHandling()
const { setMode } = useAppMode()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
function closeDialog(key: string) {
dialogStore.closeDialog({ key })
}
async function save() {
if (isSaving.value) return
const workflow = workflowStore.activeWorkflow
if (!workflow) return
isSaving.value = true
try {
await workflowService.saveWorkflow(workflow)
} catch (e) {
toastErrorHandler(e)
} finally {
isSaving.value = false
}
}
function saveAs() {
if (isSaving.value) return
const workflow = workflowStore.activeWorkflow
if (!workflow) return
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename: workflow.filename,
defaultOpenAsApp: workflow.initialMode !== 'graph',
onSave: handleSaveAs,
onClose: () => closeDialog(SAVE_DIALOG_KEY)
}
})
}
async function handleSaveAs(filename: string, openAsApp: boolean) {
if (isSaving.value) return
isSaving.value = true
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
const saved = await workflowService.saveWorkflowAs(workflow, {
filename
})
if (!saved) return
const activeWorkflow = workflowStore.activeWorkflow
if (!activeWorkflow) return
setWorkflowDefaultView(activeWorkflow, openAsApp)
closeDialog(SAVE_DIALOG_KEY)
showSuccessDialog(openAsApp ? 'app' : 'graph')
} catch (e) {
toastErrorHandler(e)
closeDialog(SAVE_DIALOG_KEY)
} finally {
isSaving.value = false
}
}
function showSuccessDialog(viewType: 'app' | 'graph') {
const promptText =
viewType === 'app'
? t('builderSave.successBodyApp')
: t('builderSave.successBodyGraph')
showConfirmDialog({
key: SUCCESS_DIALOG_KEY,
headerProps: {
title: t('builderSave.successTitle'),
icon: 'icon-[lucide--circle-check-big] text-green-500'
},
props: { promptText, preserveNewlines: true },
footerProps:
viewType === 'graph'
? {
cancelText: t('builderToolbar.viewApp'),
confirmText: t('linearMode.builder.exit'),
confirmVariant: 'secondary' as const,
onCancel: () => {
closeDialog(SUCCESS_DIALOG_KEY)
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
appModeStore.exitBuilder()
}
}
: {
cancelText: t('g.close'),
confirmText: t('builderToolbar.viewApp'),
confirmVariant: 'secondary' as const,
onCancel: () => closeDialog(SUCCESS_DIALOG_KEY),
onConfirm: () => {
closeDialog(SUCCESS_DIALOG_KEY)
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
}
}
})
}
return { save, saveAs, isSaving }
}

View File

@@ -4,10 +4,13 @@ import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'builder:arrange'
'builder:arrange',
'setDefaultView'
] as const
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
@@ -16,8 +19,10 @@ const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const { settingView, showDialog } = useAppSetDefaultView()
const activeStep = computed<BuilderStepId>(() => {
if (settingView.value) return 'setDefaultView'
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
@@ -42,14 +47,23 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
showDialog()
} else {
setMode(stepId)
}
}
function goBack() {
if (isFirstStep.value) return
setMode(BUILDER_STEPS[activeStepIndex.value - 1])
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
setMode(BUILDER_STEPS[activeStepIndex.value + 1])
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
}
return {
@@ -58,7 +72,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep: setMode,
navigateToStep,
goBack,
goNext
}

View File

@@ -2,15 +2,11 @@
<div
class="flex items-center gap-2 p-4 font-inter text-sm font-bold text-base-foreground"
>
<i v-if="icon" :class="cn(icon, 'size-4')" aria-hidden="true" />
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
defineProps<{
title?: string
icon?: string
}>()
</script>

View File

@@ -5,7 +5,6 @@ import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
interface ConfirmDialogOptions {
key?: string
headerProps?: ComponentAttrs<typeof ConfirmHeader>
props?: ComponentAttrs<typeof ConfirmBody>
footerProps?: ComponentAttrs<typeof ConfirmFooter>
@@ -13,9 +12,8 @@ interface ConfirmDialogOptions {
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { key, headerProps, props, footerProps } = options
const { headerProps, props, footerProps } = options
return dialogStore.showDialog({
key,
headerComponent: ConfirmHeader,
component: ConfirmBody,
footerComponent: ConfirmFooter,

View File

@@ -1,20 +1,22 @@
import { Form } from '@primevue/forms'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
@@ -39,11 +41,11 @@ vi.mock('@/composables/auth/useAuthActions', () => ({
}))
}))
const mockLoadingRef = ref(false)
let mockLoading = false
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoadingRef.value
return mockLoading
}
}))
}))
@@ -56,145 +58,259 @@ vi.mock('primevue/usetoast', () => ({
}))
}))
const forgotPasswordText = enMessages.auth.login.forgotPassword
const loginButtonText = enMessages.auth.login.loginButton
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoadingRef.value = false
mockLoading = false
})
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent(props: Record<string, unknown> = {}) {
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const user = userEvent.setup()
const result = render(SignInForm, {
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: { Form, Button, InputText, Password, ProgressSpinner }
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props
props,
...options
})
return { ...result, user }
}
function getEmailInput() {
return screen.getByPlaceholderText(enMessages.auth.login.emailPlaceholder)
}
function getPasswordInput() {
return screen.getByPlaceholderText(
enMessages.auth.login.passwordPlaceholder
)
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
)
expect(forgotPasswordSpan.classes()).toContain('cursor-not-allowed')
expect(forgotPasswordSpan.classes()).toContain('opacity-50')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const { user } = renderComponent()
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
)
const emailInput = getEmailInput()
const focusSpy = vi.spyOn(emailInput, 'focus')
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
await user.click(screen.getByText(forgotPasswordText))
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
expect(focusSpy).toHaveBeenCalled()
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.select-none'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when form is submitted with valid data', async () => {
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
await user.type(getEmailInput(), 'test@example.com')
await user.type(getPasswordInput(), 'password123')
await user.click(screen.getByRole('button', { name: loginButtonText }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form data is invalid', async () => {
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
await user.type(getEmailInput(), 'invalid-email')
await user.type(getPasswordInput(), 'password123')
await user.click(screen.getByRole('button', { name: loginButtonText }))
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
expect(onSubmit).not.toHaveBeenCalled()
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', () => {
mockLoadingRef.value = true
renderComponent()
it('shows spinner when loading', async () => {
mockLoading = true
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: loginButtonText })
).not.toBeInTheDocument()
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
renderComponent()
mockLoading = false
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: loginButtonText })
).toBeInTheDocument()
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
renderComponent()
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
const emailInput = getEmailInput()
expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-in-email')
expect(emailInput).toHaveAttribute('autocomplete', 'email')
expect(emailInput).toHaveAttribute('name', 'email')
expect(emailInput).toHaveAttribute('type', 'text')
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
renderComponent()
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
const passwordInput = getPasswordInput()
expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-in-password')
expect(passwordInput).toHaveAttribute('name', 'password')
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Forgot Password with valid email', () => {
it('calls sendPasswordReset when email is valid', async () => {
const { user } = renderComponent()
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
await user.type(getEmailInput(), 'test@example.com')
await user.click(screen.getByText(forgotPasswordText))
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
expect(mockToastAdd).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,5 +1,4 @@
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -130,10 +129,6 @@ describe('SelectionToolbox', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefMock = {
type: 'TestNode',
title: 'Test Node'
} as unknown
// Mock the canvas to avoid "getCanvas: canvas is null" errors
canvasStore.canvas = createMockCanvas()
@@ -141,8 +136,8 @@ describe('SelectionToolbox', () => {
vi.resetAllMocks()
})
function renderComponent(props = {}): { container: Element } {
const { container } = render(SelectionToolbox, {
const mountComponent = (props = {}) => {
return mount(SelectionToolbox, {
props,
global: {
plugins: [i18n, PrimeVue],
@@ -174,9 +169,7 @@ describe('SelectionToolbox', () => {
Load3DViewerButton: {
template: '<div class="load-3d-viewer-button" />'
},
MaskEditorButton: {
template: '<div class="mask-editor-button" />'
},
MaskEditorButton: { template: '<div class="mask-editor-button" />' },
DeleteButton: {
template:
'<button data-testid="delete-button" class="delete-button" />'
@@ -200,7 +193,6 @@ describe('SelectionToolbox', () => {
}
}
})
return { container }
}
describe('Button Visibility Logic', () => {
@@ -212,91 +204,91 @@ describe('SelectionToolbox', () => {
it('should show info button only for single selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(true)
// Multiple node selection - render in separate test scope
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
const { container: container2 } = renderComponent()
expect(container2.querySelector('.info-button')).toBeFalsy()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.info-button').exists()).toBe(false)
})
it('should not show info button when node definition is not found', () => {
canvasStore.selectedItems = [createMockPositionable()]
// mock nodedef and return null
nodeDefMock = null
const { container } = renderComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
// remount component
const wrapper = mountComponent()
expect(wrapper.find('.info-button').exists()).toBe(false)
})
it('should show color picker for all selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(
container.querySelector('[data-testid="color-picker-button"]')
).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
true
)
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
const { container: container2 } = renderComponent()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(
container2.querySelector('[data-testid="color-picker-button"]')
).toBeTruthy()
wrapper2.find('[data-testid="color-picker-button"]').exists()
).toBe(true)
})
it('should show frame nodes only for multiple selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.frame-nodes')).toBeFalsy()
const wrapper = mountComponent()
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
const { container: container2 } = renderComponent()
expect(container2.querySelector('.frame-nodes')).toBeTruthy()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
})
it('should show bypass button for appropriate selections', () => {
// Single node selection
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(
container.querySelector('[data-testid="bypass-button"]')
).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
// Multiple node selection
canvasStore.selectedItems = [
createMockPositionable(),
createMockPositionable()
]
const { container: container2 } = renderComponent()
expect(
container2.querySelector('[data-testid="bypass-button"]')
).toBeTruthy()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
})
it('should show common buttons for all selections', () => {
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
expect(
container.querySelector('[data-testid="delete-button"]')
).toBeTruthy()
expect(
container.querySelector('[data-testid="convert-to-subgraph-button"]')
).toBeTruthy()
expect(
container.querySelector('[data-testid="more-options-button"]')
).toBeTruthy()
wrapper.find('[data-testid="convert-to-subgraph-button"]').exists()
).toBe(true)
expect(wrapper.find('[data-testid="more-options-button"]').exists()).toBe(
true
)
})
it('should show mask editor only for single image nodes', () => {
@@ -305,14 +297,15 @@ describe('SelectionToolbox', () => {
// Single image node
isImageNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.mask-editor-button')).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
// Single non-image node
isImageNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [createMockPositionable()]
const { container: container2 } = renderComponent()
expect(container2.querySelector('.mask-editor-button')).toBeFalsy()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', () => {
@@ -321,14 +314,15 @@ describe('SelectionToolbox', () => {
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.load-3d-viewer-button')).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
// Single non-Load3D node
isLoad3dNodeSpy.mockReturnValue(false)
canvasStore.selectedItems = [createMockPositionable()]
const { container: container2 } = renderComponent()
expect(container2.querySelector('.load-3d-viewer-button')).toBeFalsy()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', () => {
@@ -341,20 +335,22 @@ describe('SelectionToolbox', () => {
{ type: 'SaveImage' }
] as LGraphNode[])
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
expect(container.querySelector('.execute-button')).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('.execute-button').exists()).toBe(true)
// Without output node selected
isOutputNodeSpy.mockReturnValue(false)
filterOutputNodesSpy.mockReturnValue([])
canvasStore.selectedItems = [createMockPositionable()]
const { container: container2 } = renderComponent()
expect(container2.querySelector('.execute-button')).toBeFalsy()
wrapper.unmount()
const wrapper2 = mountComponent()
expect(wrapper2.find('.execute-button').exists()).toBe(false)
// No selection at all
canvasStore.selectedItems = []
const { container: container3 } = renderComponent()
expect(container3.querySelector('.execute-button')).toBeFalsy()
wrapper2.unmount()
const wrapper3 = mountComponent()
expect(wrapper3.find('.execute-button').exists()).toBe(false)
})
})
@@ -362,20 +358,19 @@ describe('SelectionToolbox', () => {
it('should show dividers between button groups when both groups have buttons', () => {
// Setup single node to show info + other buttons
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
const wrapper = mountComponent()
const dividers = container.querySelectorAll('.vertical-divider')
const dividers = wrapper.findAll('.vertical-divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should not show dividers when adjacent groups are empty', () => {
// No selection should show minimal buttons and dividers
canvasStore.selectedItems = []
const { container } = renderComponent()
const wrapper = mountComponent()
expect(
container.querySelector('[data-testid="more-options-button"]')
).toBeTruthy()
const buttons = wrapper.find('.panel').element.children
expect(buttons.length).toBeGreaterThan(0) // At least MoreOptions should show
})
})
@@ -395,9 +390,9 @@ describe('SelectionToolbox', () => {
} as ReturnType<typeof useExtensionService>)
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
const wrapper = mountComponent()
expect(container.querySelector('.extension-command-button')).toBeTruthy()
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
})
it('should not render extension commands when none available', () => {
@@ -405,9 +400,47 @@ describe('SelectionToolbox', () => {
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
const wrapper = mountComponent()
expect(container.querySelector('.extension-command-button')).toBeFalsy()
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
})
})
describe('Container Styling', () => {
it('should apply minimap container styles', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.classes()).toContain('selection-toolbox')
expect(panel.classes()).toContain('absolute')
expect(panel.classes()).toContain('left-1/2')
expect(panel.classes()).toContain('rounded-lg')
})
it('should handle animation class conditionally', () => {
const mockExtensionService = vi.mocked(useExtensionService)
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = mountComponent()
const panel = wrapper.find('.panel')
expect(panel.exists()).toBe(true)
})
})
@@ -428,11 +461,10 @@ describe('SelectionToolbox', () => {
mockExtensionService.mockReturnValue(createMockExtensionService())
canvasStore.selectedItems = [createMockPositionable()]
const { container } = renderComponent()
const wrapper = mountComponent()
const panel = container.querySelector('.panel')
expect(panel).toBeTruthy()
await fireEvent.wheel(panel!)
const panel = wrapper.find('.panel')
await panel.trigger('wheel')
expect(forwardEventToCanvasSpy).toHaveBeenCalled()
})
@@ -446,12 +478,12 @@ describe('SelectionToolbox', () => {
it('should hide most buttons when no items selected', () => {
canvasStore.selectedItems = []
const { container } = renderComponent()
const wrapper = mountComponent()
expect(container.querySelector('.info-button')).toBeFalsy()
expect(container.querySelector('.color-picker-button')).toBeFalsy()
expect(container.querySelector('.frame-nodes')).toBeFalsy()
expect(container.querySelector('.bookmark-button')).toBeFalsy()
expect(wrapper.find('.info-button').exists()).toBe(false)
expect(wrapper.find('.color-picker-button').exists()).toBe(false)
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
expect(wrapper.find('.bookmark-button').exists()).toBe(false)
})
})
})

View File

@@ -1,6 +1,4 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -27,79 +25,61 @@ const JobFiltersBarStub = {
template: '<div />'
}
const testJob: JobListItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'pending'
}
const JobAssetsListStub = defineComponent({
const JobAssetsListStub = {
name: 'JobAssetsList',
setup(_, { emit }) {
return {
triggerCancel: () => emit('cancel-item', testJob),
triggerDelete: () => emit('delete-item', testJob),
triggerView: () => emit('view-item', testJob)
}
},
template: `
<div class="job-assets-list-stub">
<button data-testid="stub-cancel" @click="triggerCancel()" />
<button data-testid="stub-delete" @click="triggerDelete()" />
<button data-testid="stub-view" @click="triggerView()" />
</div>
`
})
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const defaultProps = {
headerTitle: 'Jobs',
queuedCount: 1,
selectedJobTab: 'All' as const,
selectedWorkflowFilter: 'all' as const,
selectedSortMode: 'mostRecent' as const,
displayedJobGroups: [],
hasFailedJobs: false
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'pending'
})
const stubs = {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
}
const mountComponent = () =>
mount(QueueOverlayExpanded, {
props: {
headerTitle: 'Jobs',
queuedCount: 1,
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
displayedJobGroups: [],
hasFailedJobs: false
},
global: {
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
}
}
})
describe('QueueOverlayExpanded', () => {
it('renders JobAssetsList', () => {
const { container } = render(QueueOverlayExpanded, {
props: defaultProps,
global: { stubs }
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.job-assets-list-stub')).toBeTruthy()
const wrapper = mountComponent()
expect(wrapper.find('.job-assets-list-stub').exists()).toBe(true)
})
it('re-emits list item actions from JobAssetsList', async () => {
const user = userEvent.setup()
const onCancelItem = vi.fn<(item: JobListItem) => void>()
const onDeleteItem = vi.fn<(item: JobListItem) => void>()
const onViewItem = vi.fn<(item: JobListItem) => void>()
const wrapper = mountComponent()
const job = createJob()
const jobAssetsList = wrapper.findComponent({ name: 'JobAssetsList' })
render(QueueOverlayExpanded, {
props: { ...defaultProps, onCancelItem, onDeleteItem, onViewItem },
global: { stubs }
})
jobAssetsList.vm.$emit('cancel-item', job)
jobAssetsList.vm.$emit('delete-item', job)
jobAssetsList.vm.$emit('view-item', job)
await wrapper.vm.$nextTick()
await user.click(screen.getByTestId('stub-cancel'))
await user.click(screen.getByTestId('stub-delete'))
await user.click(screen.getByTestId('stub-view'))
expect(onCancelItem).toHaveBeenCalledWith(testJob)
expect(onDeleteItem).toHaveBeenCalledWith(testJob)
expect(onViewItem).toHaveBeenCalledWith(testJob)
expect(wrapper.emitted('cancelItem')?.[0]).toEqual([job])
expect(wrapper.emitted('deleteItem')?.[0]).toEqual([job])
expect(wrapper.emitted('viewItem')?.[0]).toEqual([job])
})
})

View File

@@ -1,8 +1,4 @@
/* eslint-disable vue/one-component-per-file -- test stubs */
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
@@ -18,36 +14,7 @@ const JobDetailsPopoverStub = defineComponent({
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template:
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
})
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
props: {
previewUrl: { type: String, default: undefined },
isVideoPreview: { type: Boolean, default: false },
previewAlt: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconClass: { type: String, default: undefined },
primaryText: { type: String, default: undefined },
secondaryText: { type: String, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined }
},
setup(_, { emit }) {
return { emitPreviewClick: () => emit('preview-click') }
},
template: `
<div class="assets-list-item-stub"
:data-preview-url="previewUrl"
:data-is-video="isVideoPreview">
<span>{{ primaryText }}</span>
<button data-testid="preview-trigger" @click="emitPreviewClick" />
<i v-if="iconName && !previewUrl" :class="iconName" @click="emitPreviewClick" />
<slot name="actions" />
</div>
`
template: '<div class="job-details-popover-stub" />'
})
vi.mock('vue-i18n', () => {
@@ -105,12 +72,7 @@ const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
...overrides
})
function renderJobAssetsList(
jobs: JobListItem[],
callbacks: {
onViewItem?: (item: JobListItem) => void
} = {}
) {
const mountJobAssetsList = (jobs: JobListItem[]) => {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
@@ -119,23 +81,15 @@ function renderJobAssetsList(
}
]
const user = userEvent.setup()
const result = render(JobAssetsList, {
props: {
displayedJobGroups,
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
},
return mount(JobAssetsList, {
props: { displayedJobGroups },
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub,
AssetsListItem: AssetsListItemStub
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
return { ...result, user }
}
function createDomRect({
@@ -170,23 +124,24 @@ afterEach(() => {
describe('JobAssetsList', () => {
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
const { user } = renderJobAssetsList([job], { onViewItem })
const wrapper = mountJobAssetsList([job])
await user.click(screen.getByTestId('preview-trigger'))
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
listItem.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(onViewItem).toHaveBeenCalledWith(job)
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const wrapper = mountJobAssetsList([job])
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(onViewItem).toHaveBeenCalledWith(job)
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
@@ -194,18 +149,16 @@ describe('JobAssetsList', () => {
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const wrapper = mountJobAssetsList([job])
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(
'/api/view/job-1.webm'
)
expect(stubRoot.getAttribute('data-is-video')).toBe('true')
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
expect(listItem.props('isVideoPreview')).toBe(true)
await user.dblClick(stubRoot)
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(onViewItem).toHaveBeenCalledWith(job)
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
@@ -213,13 +166,14 @@ describe('JobAssetsList', () => {
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const wrapper = mountJobAssetsList([job])
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(onViewItem).toHaveBeenCalledWith(job)
await listItem.find('i').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
@@ -227,13 +181,13 @@ describe('JobAssetsList', () => {
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const wrapper = mountJobAssetsList([job])
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(onViewItem).not.toHaveBeenCalled()
expect(wrapper.emitted('viewItem')).toBeUndefined()
})
it('emits viewItem from the View button for completed jobs without preview output', async () => {
@@ -241,90 +195,92 @@ describe('JobAssetsList', () => {
iconImageUrl: undefined,
taskRef: createTaskRef()
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList([job], { onViewItem })
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await jobRow.trigger('mouseenter')
const viewButton = wrapper
.findAll('button')
.find((button) => button.text() === 'menuLabels.View')
expect(viewButton).toBeDefined()
await fireEvent.click(screen.getByText('menuLabels.View'))
await viewButton!.trigger('click')
await nextTick()
expect(onViewItem).toHaveBeenCalledWith(job)
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('shows and hides the job details popover with hover delays', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(199)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
const popoverStub = container.querySelector('.job-details-popover-stub')!
expect(popoverStub).not.toBeNull()
expect(popoverStub.getAttribute('data-job-id')).toBe(job.id)
expect(popoverStub.getAttribute('data-workflow-id')).toBe('workflow-1')
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: job.id,
workflowId: 'workflow-1'
})
await fireEvent.mouseLeave(jobRow)
await jobRow.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(149)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('keeps the job details popover open while hovering the popover', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
await fireEvent.mouseLeave(jobRow)
await jobRow.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const popoverWrapper = container.querySelector('.job-details-popover')!
expect(popoverWrapper).not.toBeNull()
const popover = wrapper.find('.job-details-popover')
expect(popover.exists()).toBe(true)
await fireEvent.mouseEnter(popoverWrapper)
await popover.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
await fireEvent.mouseLeave(popoverWrapper)
await popover.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(149)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).not.toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 40,
@@ -333,23 +289,22 @@ describe('JobAssetsList', () => {
})
)
await fireEvent.mouseEnter(jobRow)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('style')).toContain('left: 248px;')
const popover = wrapper.find('.job-details-popover')
expect(popover.attributes('style')).toContain('left: 248px;')
})
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
vi.spyOn(jobRow.element, 'getBoundingClientRect').mockReturnValue(
createDomRect({
top: 100,
left: 980,
@@ -358,89 +313,83 @@ describe('JobAssetsList', () => {
})
)
await fireEvent.mouseEnter(jobRow)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = container.querySelector('.job-details-popover')!
expect(popover.getAttribute('style')).toContain('left: 672px;')
const popover = wrapper.find('.job-details-popover')
expect(popover.attributes('style')).toContain('left: 672px;')
})
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList([firstJob, secondJob])
const wrapper = mountJobAssetsList([firstJob, secondJob])
const firstRow = wrapper.find('[data-job-id="job-1"]')
const secondRow = wrapper.find('[data-job-id="job-2"]')
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
await fireEvent.mouseEnter(firstRow)
await firstRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popoverJobId = container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
expect(popoverJobId).toBe('job-1')
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
'job-1'
)
await fireEvent.mouseLeave(firstRow)
await fireEvent.mouseEnter(secondRow)
await firstRow.trigger('mouseleave')
await secondRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(100)
await nextTick()
await fireEvent.mouseLeave(secondRow)
await secondRow.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList([firstJob, secondJob])
const wrapper = mountJobAssetsList([firstJob, secondJob])
const firstRow = wrapper.find('[data-job-id="job-1"]')
const secondRow = wrapper.find('[data-job-id="job-2"]')
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
await fireEvent.mouseEnter(firstRow)
await firstRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const firstPopoverJobId = container
.querySelector('.job-details-popover-stub')
?.getAttribute('data-job-id')
expect(firstPopoverJobId).toBe('job-1')
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
'job-1'
)
await fireEvent.mouseLeave(firstRow)
await fireEvent.mouseEnter(secondRow)
await firstRow.trigger('mouseleave')
await secondRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(50)
await nextTick()
const popoverStub = container.querySelector('.job-details-popover-stub')!
expect(popoverStub).not.toBeNull()
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props('jobId')).toBe('job-2')
})
it('does not show details if the hovered row disappears before the show delay ends', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container, rerender } = renderJobAssetsList([job])
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await rerender({ displayedJobGroups: [] })
await jobRow.trigger('mouseenter')
await wrapper.setProps({ displayedJobGroups: [] })
await nextTick()
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
expect(container.querySelector('.job-details-popover')).toBeNull()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
expect(wrapper.find('.job-details-popover').exists()).toBe(false)
})
})

View File

@@ -1,8 +1,8 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -10,12 +10,29 @@ import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
const mockStoreRefs = vi.hoisted(() => ({
visible: { value: false },
newSearchBoxEnabled: { value: true }
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('pinia', async () => {
const actual = await vi.importActual('pinia')
return {
...(actual as Record<string, unknown>),
storeToRefs: () => mockStoreRefs
}
})
vi.mock('@/stores/workspace/searchBoxStore', () => ({
useSearchBoxStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
getCanvasCenter: vi.fn(() => [0, 0]),
@@ -51,9 +68,13 @@ vi.mock('@/stores/nodeDefStore', () => ({
})
}))
type EmitAddFilter = (
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => void
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
template: '<div class="node-search-box" />'
})
function createFilter(
id: string,
@@ -72,33 +93,15 @@ describe('NodeSearchBoxPopover', () => {
messages: { en: {} }
})
function renderComponent() {
let emitAddFilter: EmitAddFilter | null = null
beforeEach(() => {
setActivePinia(createPinia())
mockStoreRefs.visible.value = false
})
const NodeSearchBoxStub = defineComponent({
name: 'NodeSearchBox',
props: {
filters: { type: Array, default: () => [] }
},
emits: ['addFilter'],
setup(props, { emit }) {
emitAddFilter = (filter) => emit('addFilter', filter)
const filterCount = computed(() => props.filters.length)
return { filterCount }
},
template: '<output aria-label="filter count">{{ filterCount }}</output>'
})
const pinia = createTestingPinia({
stubActions: false,
initialState: {
searchBox: { visible: false }
}
})
const result = render(NodeSearchBoxPopover, {
const mountComponent = () => {
return mount(NodeSearchBoxPopover, {
global: {
plugins: [i18n, PrimeVue, pinia],
plugins: [i18n, PrimeVue],
stubs: {
NodeSearchBox: NodeSearchBoxStub,
Dialog: {
@@ -108,53 +111,63 @@ describe('NodeSearchBoxPopover', () => {
}
}
})
if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount')
return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter }
}
describe('addFilter duplicate prevention', () => {
it('should add a filter when no duplicates exist', async () => {
const { emitAddFilter } = renderComponent()
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
const filters = searchBox.props('filters') as FuseFilterWithValue<
ComfyNodeDefImpl,
string
>[]
expect(filters).toHaveLength(1)
expect(filters[0]).toEqual(
expect.objectContaining({
filterDef: expect.objectContaining({ id: 'outputType' }),
value: 'IMAGE'
})
)
})
it('should not add a duplicate filter with same id and value', async () => {
const { emitAddFilter } = renderComponent()
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('1')
expect(searchBox.props('filters')).toHaveLength(1)
})
it('should allow filters with same id but different values', async () => {
const { emitAddFilter } = renderComponent()
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('outputType', 'MASK'))
await nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
await wrapper.vm.$nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
expect(searchBox.props('filters')).toHaveLength(2)
})
it('should allow filters with different ids but same value', async () => {
const { emitAddFilter } = renderComponent()
const wrapper = mountComponent()
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
emitAddFilter(createFilter('outputType', 'IMAGE'))
await nextTick()
emitAddFilter(createFilter('inputType', 'IMAGE'))
await nextTick()
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
await wrapper.vm.$nextTick()
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
await wrapper.vm.$nextTick()
expect(screen.getByLabelText('filter count')).toHaveTextContent('2')
expect(searchBox.props('filters')).toHaveLength(2)
})
})
})

View File

@@ -143,11 +143,16 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button size="icon" @click="handleDownloadSelected">
<Button
size="icon"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
@@ -156,12 +161,17 @@
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button variant="secondary" @click="handleDownloadSelected">
<Button
variant="secondary"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>

View File

@@ -1,51 +1,71 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
error: boolean
}
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
describe('BaseThumbnail', () => {
function renderThumbnail(
props: Partial<ComponentProps<typeof BaseThumbnail>> = {}
) {
return render(BaseThumbnail, {
props: props as ComponentProps<typeof BaseThumbnail>,
const mountThumbnail = (props = {}, slots = {}) => {
return mount(BaseThumbnail, {
props,
slots: {
default: '<img src="/test.jpg" alt="test" />'
default: '<img src="/test.jpg" alt="test" />',
...slots
}
})
}
it('renders slot content', () => {
renderThumbnail()
expect(screen.getByAltText('test')).toBeTruthy()
const wrapper = mountThumbnail()
expect(wrapper.find('img').exists()).toBe(true)
})
it('applies hover zoom with correct style', () => {
renderThumbnail({ isHovered: true })
const contentDiv = screen.getByTestId('thumbnail-content')
expect(contentDiv).toHaveStyle({ transform: 'scale(1.04)' })
const wrapper = mountThumbnail({ isHovered: true })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toContain('transform')
expect(contentDiv.attributes('style')).toContain('scale')
})
it('applies custom hover zoom value', () => {
renderThumbnail({ hoverZoom: 10, isHovered: true })
const contentDiv = screen.getByTestId('thumbnail-content')
expect(contentDiv).toHaveStyle({ transform: 'scale(1.1)' })
const wrapper = mountThumbnail({ hoverZoom: 10, isHovered: true })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toContain('scale(1.1)')
})
it('does not apply scale when not hovered', () => {
renderThumbnail({ isHovered: false })
const contentDiv = screen.getByTestId('thumbnail-content')
expect(contentDiv).not.toHaveAttribute('style')
const wrapper = mountThumbnail({ isHovered: false })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toBeUndefined()
})
it('shows error state when image fails to load', async () => {
renderThumbnail()
const img = screen.getByAltText('test')
await fireEvent.error(img)
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'/assets/images/default-template.png'
)
const wrapper = mountThumbnail()
const vm = wrapper.vm as ComponentInstance
// Manually set error since useEventListener is mocked
vm.error = true
await nextTick()
expect(
wrapper.find('img[src="/assets/images/default-template.png"]').exists()
).toBe(true)
})
it('applies transition classes to content', () => {
const wrapper = mountThumbnail()
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.classes()).toContain('transform-gpu')
expect(contentDiv.classes()).toContain('transition-transform')
expect(contentDiv.classes()).toContain('duration-1000')
expect(contentDiv.classes()).toContain('ease-out')
})
})

View File

@@ -5,7 +5,6 @@
<div
v-if="!error"
ref="contentRef"
data-testid="thumbnail-content"
class="size-full transform-gpu transition-transform duration-1000 ease-out"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined

View File

@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/Button.vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -37,7 +38,7 @@ vi.mock('firebase/auth', () => ({
// Mock pinia
vi.mock('pinia', () => ({
storeToRefs: vi.fn((store: Record<string, unknown>) => store)
storeToRefs: vi.fn((store) => store)
}))
// Mock the useFeatureFlags composable
@@ -90,25 +91,13 @@ vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
// eslint-disable-next-line vue/one-component-per-file
default: defineComponent({
default: {
name: 'CurrentUserPopoverLegacyMock',
emits: ['close'],
setup(_, { emit }) {
return () =>
h('div', [
'Popover Content',
h(
'button',
{
'data-testid': 'close-popover',
onClick: () => emit('close')
},
'Close'
)
])
}
})
render() {
return h('div', 'Popover Content')
},
emits: ['close']
}
}))
describe('CurrentUserButton', () => {
@@ -121,66 +110,63 @@ describe('CurrentUserButton', () => {
mockIsCloud.value = false
})
function renderComponent() {
const user = userEvent.setup()
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
const { stubButton = true } = options ?? {}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const result = render(CurrentUserButton, {
return mount(CurrentUserButton, {
global: {
plugins: [i18n],
stubs: {
// eslint-disable-next-line vue/one-component-per-file
Popover: defineComponent({
setup(_, { slots, expose }) {
const shown = ref(false)
expose({
toggle: () => {
shown.value = !shown.value
},
hide: () => {
shown.value = false
}
})
return () => (shown.value ? h('div', slots.default?.()) : null)
// Use shallow mount for popover to make testing easier
Popover: {
template: '<div><slot></slot></div>',
methods: {
toggle: vi.fn(),
hide: vi.fn()
}
})
},
...(stubButton ? { Button: true } : {})
}
}
})
return { user, ...result }
}
it('renders correctly when user is logged in', () => {
renderComponent()
expect(
screen.getByRole('button', { name: 'Current user' })
).toBeInTheDocument()
const wrapper = mountComponent()
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('toggles popover on button click', async () => {
const { user } = renderComponent()
const wrapper = mountComponent()
const popoverToggleSpy = vi.fn()
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
// Override the ref with a mock implementation
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { toggle: popoverToggleSpy }
await user.click(screen.getByRole('button', { name: 'Current user' }))
expect(screen.getByText('Popover Content')).toBeInTheDocument()
await wrapper.findComponent(Button).trigger('click')
expect(popoverToggleSpy).toHaveBeenCalled()
})
it('hides popover when closePopover is called', async () => {
const { user } = renderComponent()
const wrapper = mountComponent()
await user.click(screen.getByRole('button', { name: 'Current user' }))
expect(screen.getByText('Popover Content')).toBeInTheDocument()
// Replace the popover.hide method with a spy
const popoverHideSpy = vi.fn()
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { hide: popoverHideSpy }
await user.click(screen.getByTestId('close-popover'))
// Directly call the closePopover method through the component instance
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.closePopover()
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
})
it('shows UserAvatar in personal workspace', () => {
@@ -189,9 +175,9 @@ describe('CurrentUserButton', () => {
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
renderComponent()
expect(screen.getByText('Avatar')).toBeInTheDocument()
expect(screen.queryByText('WorkspaceProfilePic')).not.toBeInTheDocument()
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('Avatar')
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
})
it('shows WorkspaceProfilePic in team workspace', () => {
@@ -201,8 +187,8 @@ describe('CurrentUserButton', () => {
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
renderComponent()
expect(screen.getByText('WorkspaceProfilePic')).toBeInTheDocument()
expect(screen.queryByText('Avatar')).not.toBeInTheDocument()
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('WorkspaceProfilePic')
expect(wrapper.html()).not.toContain('Avatar')
})
})

View File

@@ -3682,31 +3682,28 @@
"outputsDescription": "Choose outputs",
"arrange": "Preview",
"arrangeDescription": "Review app layout",
"defaultView": "Set a default view",
"defaultViewDescription": "Choose how this opens",
"connectOutput": "Connect an output",
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
"connectOutputBody2": "Switch to the 'Output' step and click on output nodes to add them here.",
"switchToOutputs": "Switch to Outputs",
"defaultViewTitle": "Set the default view for this workflow",
"defaultViewLabel": "By default, this workflow will open as:",
"app": "App",
"appDescription": "Opens as an app by default",
"nodeGraph": "Node graph",
"nodeGraphDescription": "Opens as node graph by default",
"defaultModeAppliedTitle": "Successfully set",
"defaultModeAppliedAppBody": "This workflow will open in App Mode by default from now on.",
"defaultModeAppliedAppPrompt": "Would you like to view it now?",
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
"viewApp": "View app",
"saveAs": "Save as",
"filename": "Filename",
"exitToWorkflow": "Exit to workflow",
"emptyWorkflowTitle": "This workflow has no nodes",
"emptyWorkflowPrompt": "Do you want to start with a template?"
},
"builderFooter": {
"opensAsApp": "Open as an {mode}",
"opensAsGraph": "Open as a {mode}"
},
"builderSave": {
"successTitle": "Successfully saved",
"successBody": "Would you like to view it now?",
"successBodyApp": "This workflow will open in App Mode by default from now on.\n\nWould you like to view it now?",
"successBodyGraph": "This workflow will open as a node graph."
},
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"

View File

@@ -23,7 +23,6 @@ import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
@@ -382,9 +381,6 @@ export const useWorkflowService = () => {
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
// Save subgraph viewport before the canvas gets overwritten
useSubgraphNavigationStore().saveCurrentViewport()
}
}

View File

@@ -332,31 +332,6 @@ describe('appModeStore', () => {
})
})
it('calls checkState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
await nextTick()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('calls checkState when input is deselected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
store.selectedInputs.push([42, 'prompt'])
await nextTick()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.splice(0, 1)
await nextTick()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('reflects input changes in linearData', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()

View File

@@ -89,7 +89,6 @@ export const useAppModeStore = defineStore('appMode', () => {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
workflowStore.activeWorkflow?.changeTracker?.checkState()
},
{ deep: true }
)

View File

@@ -9,7 +9,6 @@ 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 { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
@@ -35,44 +34,20 @@ export const useSubgraphNavigationStore = defineStore(
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
const idStack = ref<string[]>([])
/** LRU cache for viewport states. Key: `workflowPath:graphId` */
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
const viewportCache = new QuickLRU<string, DragAndScaleState>({
maxSize: VIEWPORT_CACHE_MAX_SIZE
})
/** Get the ID of the root graph for the currently active workflow. */
/**
* Get the ID of the root graph for the currently active workflow.
* @returns The ID of the root graph for the currently active workflow.
*/
const getCurrentRootGraphId = () => {
const canvas = canvasStore.getCanvas()
return canvas.graph?.rootGraph?.id ?? 'root'
}
/**
* Set by saveCurrentViewport() (called from beforeLoadNewGraph) to
* prevent onNavigated from re-saving a stale viewport during the
* workflow switch transition. Uses setTimeout instead of rAF so the
* flag resets even when the tab is backgrounded.
*/
let isWorkflowSwitching = false
// ── Helpers ──────────────────────────────────────────────────────
/** Build a workflow-scoped cache key. */
function buildCacheKey(
graphId: string,
workflowRef?: { path?: string } | null
): string {
const wf = workflowRef ?? workflowStore.activeWorkflow
const prefix = wf?.path ?? ''
return `${prefix}:${graphId}`
}
/** ID of the graph currently shown on the canvas. */
function getActiveGraphId(): string {
const canvas = canvasStore.getCanvas()
return canvas?.subgraph?.id ?? getCurrentRootGraphId()
}
// ── Navigation stack ─────────────────────────────────────────────
/**
* A stack representing subgraph navigation history from the root graph to
* the current opened subgraph.
@@ -85,6 +60,7 @@ export const useSubgraphNavigationStore = defineStore(
/**
* Restore the navigation stack from a list of subgraph IDs.
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
* @see exportState
*/
const restoreState = (subgraphIds: string[]) => {
@@ -94,74 +70,69 @@ export const useSubgraphNavigationStore = defineStore(
/**
* Export the navigation stack as a list of subgraph IDs.
* @returns The list of subgraph IDs, ending with the currently active subgraph.
* @see restoreState
*/
const exportState = () => [...idStack.value]
// ── Viewport save / restore ──────────────────────────────────────
/** Get the current viewport state, or null if the canvas is not available. */
/**
* Get the current viewport state.
* @returns The current viewport state, or null if the canvas is not available.
*/
const getCurrentViewport = (): DragAndScaleState | null => {
const canvas = canvasStore.getCanvas()
if (!canvas) return null
return {
scale: canvas.ds.state.scale,
offset: [...canvas.ds.state.offset]
}
}
/** Save the current viewport state for a graph. */
function saveViewport(graphId: string, workflowRef?: object | null): void {
/**
* Save the current viewport state.
* @param graphId The graph ID to save for. Use 'root' for root graph, or omit to use current context.
*/
const saveViewport = (graphId: string) => {
const viewport = getCurrentViewport()
if (!viewport) return
viewportCache.set(buildCacheKey(graphId, workflowRef), viewport)
viewportCache.set(graphId, viewport)
}
/** Apply a viewport state to the canvas. */
function applyViewport(viewport: DragAndScaleState): void {
/**
* Restore viewport state for a graph.
* @param graphId The graph ID to restore. Use 'root' for root graph, or omit to use current context.
*/
const restoreViewport = (graphId: string) => {
const viewport = viewportCache.get(graphId)
if (!viewport) return
const canvas = app.canvas
if (!canvas) return
canvas.ds.scale = viewport.scale
canvas.ds.offset[0] = viewport.offset[0]
canvas.ds.offset[1] = viewport.offset[1]
canvas.setDirty(true, true)
}
function restoreViewport(graphId: string): void {
const canvas = app.canvas
if (!canvas) return
const expectedKey = buildCacheKey(graphId)
const viewport = viewportCache.get(expectedKey)
if (viewport) {
applyViewport(viewport)
return
}
// Cache miss — fit to content after the canvas has the new graph.
// rAF fires after layout + paint, when nodes are positioned.
const expectedGraphId = graphId
requestAnimationFrame(() => {
if (getActiveGraphId() !== expectedGraphId) return
useLitegraphService().fitView()
})
}
// ── Navigation handler ───────────────────────────────────────────
function onNavigated(
/**
* Update the navigation stack when the active subgraph changes.
* @param subgraph The new active subgraph.
* @param prevSubgraph The previous active subgraph.
*/
const onNavigated = (
subgraph: Subgraph | undefined,
prevSubgraph: Subgraph | undefined
): void {
// During a workflow switch, beforeLoadNewGraph already saved the
// outgoing viewport — skip the save here to avoid caching stale
// canvas state from the transition.
if (!isWorkflowSwitching) {
if (prevSubgraph) {
saveViewport(prevSubgraph.id)
} else if (!prevSubgraph && subgraph) {
saveViewport(getCurrentRootGraphId())
}
) => {
// Save viewport state for the graph we're leaving
if (prevSubgraph) {
// Leaving a subgraph
saveViewport(prevSubgraph.id)
} else if (!prevSubgraph && subgraph) {
// Leaving root graph to enter a subgraph
saveViewport(getCurrentRootGraphId())
}
const isInRootGraph = !subgraph
@@ -176,22 +147,20 @@ export const useSubgraphNavigationStore = defineStore(
if (isInReachableSubgraph) {
idStack.value = [...path]
} else {
// Treat as if opening a new subgraph
idStack.value = [subgraph.id]
}
// Always try to restore viewport for the target subgraph
restoreViewport(subgraph.id)
}
// ── Watchers ─────────────────────────────────────────────────────
// Sync flush ensures we capture the outgoing viewport before any other
// watchers or DOM updates from the same state change mutate the canvas.
// Update navigation stack when opened subgraph changes (also triggers when switching workflows)
watch(
() => workflowStore.activeSubgraph,
(newValue, oldValue) => {
onNavigated(newValue, oldValue)
},
{ flush: 'sync' }
}
)
//Allow navigation with forward/back buttons
@@ -260,16 +229,6 @@ export const useSubgraphNavigationStore = defineStore(
watch(() => canvasStore.currentGraph, updateHash)
watch(routeHash, () => navigateToHash(String(routeHash.value)))
/** Save the current viewport for the active graph/workflow. Called by
* workflowService.beforeLoadNewGraph() before the canvas is overwritten. */
function saveCurrentViewport(): void {
saveViewport(getActiveGraphId())
isWorkflowSwitching = true
setTimeout(() => {
isWorkflowSwitching = false
}, 0)
}
return {
activeSubgraph,
navigationStack,
@@ -277,9 +236,7 @@ export const useSubgraphNavigationStore = defineStore(
exportState,
saveViewport,
restoreViewport,
saveCurrentViewport,
updateHash,
/** @internal Exposed for test assertions only. */
viewportCache
}
}

View File

@@ -18,39 +18,32 @@ const { mockSetDirty } = vi.hoisted(() => ({
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: undefined as unknown,
graph: undefined as unknown,
subgraph: null,
ds: {
scale: 1,
offset: [0, 0],
state: { scale: 1, offset: [0, 0] },
fitToBounds: vi.fn()
state: {
scale: 1,
offset: [0, 0]
}
},
setDirty: mockSetDirty,
get empty() {
return true
}
setDirty: mockSetDirty
}
const mockGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn(),
id: 'root'
}
mockCanvas.graph = mockGraph
return {
app: {
graph: mockGraph,
rootGraph: mockGraph,
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: mockCanvas
}
}
})
// Mock canvasStore
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => app.canvas
@@ -58,165 +51,141 @@ 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 })
}))
// Get reference to mock canvas
const mockCanvas = app.canvas
let rafCallbacks: FrameRequestCallback[] = []
describe('useSubgraphNavigationStore - Viewport Persistence', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
rafCallbacks = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.push(cb)
return rafCallbacks.length
})
mockCanvas.subgraph = undefined
mockCanvas.graph = app.graph
// Reset canvas state
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.state.scale = 1
mockCanvas.ds.state.offset = [0, 0]
mockSetDirty.mockClear()
mockFitView.mockClear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('cache key isolation', () => {
it('isolates viewport by workflow — same graphId returns different values', () => {
const store = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Save viewport under workflow A
workflowStore.activeWorkflow = {
path: 'wfA.json'
} as typeof workflowStore.activeWorkflow
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [10, 20]
store.saveViewport('root')
// Save different viewport under workflow B
workflowStore.activeWorkflow = {
path: 'wfB.json'
} as typeof workflowStore.activeWorkflow
mockCanvas.ds.state.scale = 5
mockCanvas.ds.state.offset = [99, 88]
store.saveViewport('root')
// Restore under A — should get A's values
workflowStore.activeWorkflow = {
path: 'wfA.json'
} as typeof workflowStore.activeWorkflow
store.restoreViewport('root')
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([10, 20])
})
})
describe('saveViewport', () => {
it('saves viewport state for root graph', () => {
const store = useSubgraphNavigationStore()
it('should save viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 200]
store.saveViewport('root')
// Save viewport for root
navigationStore.saveViewport('root')
expect(store.viewportCache.get(':root')).toEqual({
// Check it was saved
const saved = navigationStore.viewportCache.get('root')
expect(saved).toEqual({
scale: 2,
offset: [100, 200]
})
})
it('saves viewport state for subgraph', () => {
const store = useSubgraphNavigationStore()
it('should save viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 1.5
mockCanvas.ds.state.offset = [50, 75]
store.saveViewport('subgraph-123')
// Save viewport for subgraph
navigationStore.saveViewport('subgraph-123')
expect(store.viewportCache.get(':subgraph-123')).toEqual({
// Check it was saved
const saved = navigationStore.viewportCache.get('subgraph-123')
expect(saved).toEqual({
scale: 1.5,
offset: [50, 75]
})
})
it('should save viewport for current context when no ID provided', () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock being in a subgraph
const mockSubgraph = { id: 'sub-456' }
workflowStore.activeSubgraph = mockSubgraph as Subgraph
// Set viewport state
mockCanvas.ds.state.scale = 3
mockCanvas.ds.state.offset = [10, 20]
// Save viewport without ID (should default to root since activeSubgraph is not tracked by navigation store)
navigationStore.saveViewport('sub-456')
// Should save for the specified subgraph
const saved = navigationStore.viewportCache.get('sub-456')
expect(saved).toEqual({
scale: 3,
offset: [10, 20]
})
})
})
describe('restoreViewport', () => {
it('restores cached viewport', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
it('should restore viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
store.restoreViewport('root')
// Save a viewport state
navigationStore.viewportCache.set('root', {
scale: 2.5,
offset: [150, 250]
})
// Restore it
navigationStore.restoreViewport('root')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(2.5)
expect(mockCanvas.ds.offset).toEqual([150, 250])
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('does not mutate canvas synchronously on cache miss', () => {
const store = useSubgraphNavigationStore()
it('should restore viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Save a viewport state
navigationStore.viewportCache.set('sub-789', {
scale: 0.75,
offset: [-50, -100]
})
// Restore it
navigationStore.restoreViewport('sub-789')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(0.75)
expect(mockCanvas.ds.offset).toEqual([-50, -100])
})
it('should do nothing if no saved viewport exists', () => {
const navigationStore = useSubgraphNavigationStore()
// Reset canvas
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockSetDirty.mockClear()
store.restoreViewport('non-existent')
// Try to restore non-existent viewport
navigationStore.restoreViewport('non-existent')
// Should not change canvas synchronously
// Canvas should not change
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockSetDirty).not.toHaveBeenCalled()
// But should have scheduled a rAF
expect(rafCallbacks).toHaveLength(1)
})
it('calls fitView on cache miss after rAF fires', () => {
const store = useSubgraphNavigationStore()
// Ensure no cached entry
store.viewportCache.delete(':root')
// 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()
})
it('skips fitView if active graph changed before rAF fires', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
store.restoreViewport('root')
expect(rafCallbacks).toHaveLength(1)
// Simulate graph switching away before rAF fires
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[0](performance.now())
expect(mockFitView).not.toHaveBeenCalled()
})
})
describe('navigation integration', () => {
it('saves and restores viewport when navigating between subgraphs', async () => {
const store = useSubgraphNavigationStore()
it('should save and restore viewport when navigating between subgraphs', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Create mock subgraph with both _nodes and nodes properties
const mockRootGraph = {
_nodes: [],
nodes: [],
@@ -230,72 +199,84 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
nodes: []
}
// Start at root with custom viewport
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 100]
// Enter subgraph
// Navigate to subgraph
workflowStore.activeSubgraph = subgraph1 as Partial<Subgraph> as Subgraph
await nextTick()
// Root viewport saved
expect(store.viewportCache.get(':root')).toEqual({
scale: 2,
offset: [100, 100]
})
// Root viewport should have been saved automatically
const rootViewport = navigationStore.viewportCache.get('root')
expect(rootViewport).toBeDefined()
expect(rootViewport?.scale).toBe(2)
expect(rootViewport?.offset).toEqual([100, 100])
// Change viewport in subgraph
mockCanvas.ds.state.scale = 0.5
mockCanvas.ds.state.offset = [-50, -50]
// Exit subgraph
// Navigate back to root
workflowStore.activeSubgraph = undefined
await nextTick()
// Subgraph viewport saved
expect(store.viewportCache.get(':sub1')).toEqual({
scale: 0.5,
offset: [-50, -50]
})
// Subgraph viewport should have been saved automatically
const sub1Viewport = navigationStore.viewportCache.get('sub1')
expect(sub1Viewport).toBeDefined()
expect(sub1Viewport?.scale).toBe(0.5)
expect(sub1Viewport?.offset).toEqual([-50, -50])
// Root viewport restored
// Root viewport should be restored automatically
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('preserves pre-existing cache entries across workflow switches', async () => {
const store = useSubgraphNavigationStore()
it('should preserve viewport cache when switching workflows', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
store.viewportCache.set(':root', { scale: 2, offset: [0, 0] })
store.viewportCache.set(':sub1', { scale: 1.5, offset: [10, 10] })
expect(store.viewportCache.size).toBe(2)
// Add some viewport states
navigationStore.viewportCache.set('root', { scale: 2, offset: [0, 0] })
navigationStore.viewportCache.set('sub1', {
scale: 1.5,
offset: [10, 10]
})
const wf1 = { path: 'wf1.json' } as ComfyWorkflow
const wf2 = { path: 'wf2.json' } as ComfyWorkflow
expect(navigationStore.viewportCache.size).toBe(2)
workflowStore.activeWorkflow = wf1 as typeof workflowStore.activeWorkflow
// Switch workflows
const workflow1 = { path: 'workflow1.json' } as ComfyWorkflow
const workflow2 = { path: 'workflow2.json' } as ComfyWorkflow
workflowStore.activeWorkflow = workflow1 as ReturnType<
typeof useWorkflowStore
>['activeWorkflow']
await nextTick()
workflowStore.activeWorkflow = wf2 as typeof workflowStore.activeWorkflow
workflowStore.activeWorkflow = workflow2 as ReturnType<
typeof useWorkflowStore
>['activeWorkflow']
await nextTick()
// Pre-existing entries still in cache
expect(store.viewportCache.has(':root')).toBe(true)
expect(store.viewportCache.has(':sub1')).toBe(true)
// Cache should be preserved (LRU will manage memory)
expect(navigationStore.viewportCache.size).toBe(2)
expect(navigationStore.viewportCache.has('root')).toBe(true)
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
})
it('should save/restore viewports correctly across multiple subgraphs', () => {
const navigationStore = useSubgraphNavigationStore()
navigationStore.viewportCache.set(':root', {
navigationStore.viewportCache.set('root', {
scale: 1,
offset: [0, 0]
})
navigationStore.viewportCache.set(':sub-1', {
navigationStore.viewportCache.set('sub-1', {
scale: 2,
offset: [100, 200]
})
navigationStore.viewportCache.set(':sub-2', {
navigationStore.viewportCache.set('sub-2', {
scale: 0.5,
offset: [-50, -75]
})
@@ -319,18 +300,17 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
// QuickLRU uses double-buffering: effective capacity is up to 2 * maxSize.
// Fill enough entries so the earliest ones are fully evicted.
// Keys use the workflow-scoped format (`:graphId`) matching production.
for (let i = 0; i < overflowEntryCount; i++) {
navigationStore.viewportCache.set(`:sub-${i}`, {
navigationStore.viewportCache.set(`sub-${i}`, {
scale: i + 1,
offset: [i * 10, i * 20]
})
}
expect(navigationStore.viewportCache.has(':sub-0')).toBe(false)
expect(navigationStore.viewportCache.has('sub-0')).toBe(false)
expect(
navigationStore.viewportCache.has(`:sub-${overflowEntryCount - 1}`)
navigationStore.viewportCache.has(`sub-${overflowEntryCount - 1}`)
).toBe(true)
mockCanvas.ds.scale = 99

View File

@@ -14,7 +14,6 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { vi } from 'vitest'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { ChangeTracker } from '@/scripts/changeTracker'
/**
@@ -265,18 +264,6 @@ export function createMockChangeTracker(
return partial as Partial<ChangeTracker> as ChangeTracker
}
/**
* Creates a mock LoadedComfyWorkflow with sensible defaults
*/
export function createMockLoadedWorkflow(
overrides: Partial<LoadedComfyWorkflow> | Record<string, unknown> = {}
): LoadedComfyWorkflow {
return {
changeTracker: createMockChangeTracker(),
...overrides
} as unknown as LoadedComfyWorkflow
}
/**
* Creates a mock MinimapCanvas for minimap testing
*/

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import Select from 'primevue/select'
@@ -12,8 +13,18 @@ import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
interface PackVersionSelectorVM {
getVersionCompatibility: (version: string) => unknown
}
function getVM(wrapper: VueWrapper): PackVersionSelectorVM {
return wrapper.vm as Partial<PackVersionSelectorVM> as PackVersionSelectorVM
}
// Default mock versions for reference
const defaultMockVersions = [
{
@@ -101,47 +112,46 @@ describe('PackVersionSelectorPopover', () => {
mockGetInstalledPackVersion.mockReset().mockReturnValue(undefined)
})
function renderComponent({
props = {},
onCancel,
onSubmit
}: {
props?: Record<string, unknown>
onCancel?: () => void
onSubmit?: () => void
} = {}) {
const mountComponent = ({
props = {}
}: { props?: Record<string, unknown> } = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const user = userEvent.setup()
const result = render(PackVersionSelectorPopover, {
return mount(PackVersionSelectorPopover, {
props: {
nodePack: mockNodePack,
...props,
...(onCancel ? { onCancel } : {}),
...(onSubmit ? { onSubmit } : {})
...props
},
global: {
plugins: [PrimeVue, createTestingPinia({ stubActions: false }), i18n],
components: { Listbox, VerifiedIcon, Select },
directives: { tooltip: Tooltip }
components: {
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
return { ...result, user }
}
it('fetches versions on mount', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
renderComponent()
mountComponent()
await waitForPromises()
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() =>
new Promise((resolve) =>
@@ -149,52 +159,63 @@ describe('PackVersionSelectorPopover', () => {
)
)
renderComponent()
const wrapper = mountComponent()
expect(screen.getByText('Loading versions...')).toBeInTheDocument()
expect(wrapper.text()).toContain('Loading versions...')
})
it('displays special options and version options in the listbox', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
renderComponent()
const wrapper = mountComponent()
await waitForPromises()
// Latest version (1.0.0) should be excluded from version list to avoid duplication
expect(screen.getByText(/Latest/)).toBeInTheDocument()
expect(screen.getByText('Nightly')).toBeInTheDocument()
expect(screen.getByText('0.9.0')).toBeInTheDocument()
expect(screen.getByText('0.8.0')).toBeInTheDocument()
// 1.0.0 appears only inside the "Latest (1.0.0)" label, not as a standalone option
expect(
screen.queryByRole('option', { name: '1.0.0' })
).not.toBeInTheDocument()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
const options = listbox.props('options')!
// Check that we have both special options and version options
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
// Check that special options exist
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist (excluding latest version 1.0.0)
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
it('emits cancel event when cancel button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const onCancel = vi.fn()
const { user } = renderComponent({ onCancel })
const wrapper = mountComponent()
await waitForPromises()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
const cancelButton = wrapper.findAllComponents(Button)[0]
await cancelButton.trigger('click')
expect(onCancel).toHaveBeenCalledOnce()
expect(wrapper.emitted('cancel')).toBeTruthy()
})
it('calls installPack and emits submit when install button is clicked', async () => {
// Set up the mock for this specific test
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
const wrapper = mountComponent()
await waitForPromises()
// Select version 0.9.0 by clicking its option
await user.click(screen.getByText('0.9.0'))
// Set the selected version
await wrapper.findComponent(Listbox).setValue('0.9.0')
await user.click(screen.getByRole('button', { name: 'Install' }))
const installButton = wrapper.findAllComponents(Button)[1]
await installButton.trigger('click')
// Check that installPack was called with the correct parameters
expect(mockInstallPack).toHaveBeenCalledWith(
expect.objectContaining({
id: mockNodePack.id,
@@ -204,117 +225,126 @@ describe('PackVersionSelectorPopover', () => {
})
)
expect(onSubmit).toHaveBeenCalledOnce()
// Check that submit was emitted
expect(wrapper.emitted('submit')).toBeTruthy()
})
it('is reactive to nodePack prop changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const { rerender } = renderComponent()
const wrapper = mountComponent()
await waitForPromises()
// Set up the mock for the second fetch after prop change
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await rerender({ nodePack: newNodePack })
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('nodePack.id changes', () => {
it('re-fetches versions when nodePack.id changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const { rerender } = renderComponent()
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
// Set up the mock for the second fetch
const newVersions = [
{ version: '2.0.0', createdAt: '2023-06-01' },
{ version: '1.9.0', createdAt: '2023-05-01' }
]
mockGetPackVersions.mockResolvedValueOnce(newVersions)
// Update the nodePack with a new ID
const newNodePack = {
...mockNodePack,
id: 'different-pack',
name: 'Different Pack'
}
await rerender({ nodePack: newNodePack })
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
expect(screen.getByText('2.0.0')).toBeInTheDocument()
expect(screen.getByText('1.9.0')).toBeInTheDocument()
// Check that new versions are displayed
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
})
it('does not re-fetch when nodePack changes but id remains the same', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const { rerender } = renderComponent()
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
// Update the nodePack with same ID but different properties
const updatedNodePack = {
...mockNodePack,
name: 'Updated Test Pack',
description: 'New description'
}
await rerender({ nodePack: updatedNodePack })
await wrapper.setProps({ nodePack: updatedNodePack })
await waitForPromises()
// Should NOT fetch versions again
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
})
it('maintains selected version when switching to a new pack', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const { user, container, rerender } = renderComponent()
const wrapper = mountComponent()
await waitForPromises()
// Select version 0.9.0
await user.click(screen.getByText('0.9.0'))
// Verify 0.9.0 is selected via aria-selected
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue Listbox: checking aria-selected on option element
const selectedOption = container.querySelector(
'[role="option"][aria-selected="true"]'
)
expect(selectedOption).not.toBeNull()
expect(selectedOption?.textContent).toContain('0.9.0')
// Select a specific version
const listbox = wrapper.findComponent(Listbox)
await listbox.setValue('0.9.0')
expect(listbox.props('modelValue')).toBe('0.9.0')
// Set up the mock for the second fetch
mockGetPackVersions.mockResolvedValueOnce([
{ version: '3.0.0', createdAt: '2023-07-01' },
{ version: '0.9.0', createdAt: '2023-04-01' }
])
// Update to a new pack that also has version 0.9.0
const newNodePack = {
id: 'another-pack',
name: 'Another Pack',
latest_version: { version: '3.0.0' }
}
await rerender({ nodePack: newNodePack })
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Selected version should remain 0.9.0 — verify via pi-check icon
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue Listbox: checking selected indicator icon
const checkIcons = container.querySelectorAll('.pi.pi-check')
const selectedTexts = Array.from(checkIcons).map(
// eslint-disable-next-line testing-library/no-node-access -- traversing to parent option element
(icon) => icon.closest('[role="option"]')?.textContent
)
expect(selectedTexts.some((text) => text?.includes('0.9.0'))).toBe(true)
// Selected version should remain the same if available
expect(listbox.props('modelValue')).toBe('0.9.0')
})
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when no versions exist', async () => {
// Set up the mock to return versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const packWithRepo = {
@@ -322,22 +352,20 @@ describe('PackVersionSelectorPopover', () => {
latest_version: undefined
}
const { container } = renderComponent({
props: { nodePack: packWithRepo }
const wrapper = mountComponent({
props: {
nodePack: packWithRepo
}
})
await waitForPromises()
// Nightly should be selected — verify via pi-check icon next to Nightly
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue Listbox: checking selected indicator icon
const checkIcons = container.querySelectorAll('.pi.pi-check')
const selectedTexts = Array.from(checkIcons).map(
// eslint-disable-next-line testing-library/no-node-access -- traversing to parent option element
(icon) => icon.closest('[role="option"]')?.textContent
)
expect(selectedTexts.some((text) => text?.includes('Nightly'))).toBe(true)
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
// Set up the mock to return versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const unclaimedNodePack = {
@@ -345,26 +373,25 @@ describe('PackVersionSelectorPopover', () => {
publisher: { name: 'Unclaimed' }
}
const { container } = renderComponent({
props: { nodePack: unclaimedNodePack }
const wrapper = mountComponent({
props: {
nodePack: unclaimedNodePack
}
})
await waitForPromises()
// Nightly should be selected
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue Listbox: checking selected indicator icon
const checkIcons = container.querySelectorAll('.pi.pi-check')
const selectedTexts = Array.from(checkIcons).map(
// eslint-disable-next-line testing-library/no-node-access -- traversing to parent option element
(icon) => icon.closest('[role="option"]')?.textContent
)
expect(selectedTexts.some((text) => text?.includes('Nightly'))).toBe(true)
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
describe('version compatibility checking', () => {
it('shows warning icon for incompatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return conflict for specific version
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.supported_os?.includes('linux')) {
return {
@@ -387,41 +414,103 @@ describe('PackVersionSelectorPopover', () => {
supported_accelerators: ['CUDA']
}
const { container } = renderComponent({
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- icon class query not expressible via ARIA roles
const warningIcons = container.querySelectorAll(
'.icon-\\[lucide--triangle-alert\\]'
)
// The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows verified icon for compatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return no conflicts
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const { container } = renderComponent()
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- VerifiedIcon renders SVG without accessible role
const verifiedIcons = container.querySelectorAll('svg')
// The verified icon should be shown for compatible versions
// Look for the VerifiedIcon component or SVG elements
const verifiedIcons = wrapper.findAll('svg')
expect(verifiedIcons.length).toBeGreaterThan(0)
})
it('calls checkVersionCompatibility with correct version data', async () => {
// Set up the mock for versions with specific supported data
const versionsWithCompatibility = [
{
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CUDA', 'CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
]
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = getVM(wrapper)
vm.getVersionCompatibility('1.0.0')
// Verify that checkNodeCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
})
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return version conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
const conflicts = []
if (versionData.supported_comfyui_version) {
@@ -450,23 +539,94 @@ describe('PackVersionSelectorPopover', () => {
supported_comfyui_frontend_version: '>=2.0.0'
}
const { container } = renderComponent({
const wrapper = mountComponent({
props: { nodePack: nodePackWithVersionRequirements }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- icon class query not expressible via ARIA roles
const warningIcons = container.querySelectorAll(
'.icon-\\[lucide--triangle-alert\\]'
)
// The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows banned package warnings', async () => {
it('handles latest and nightly versions using nodePack data', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
...mockNodePack.latest_version,
supported_os: ['windows'], // Match nodePack data for test consistency
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
const vm = getVM(wrapper)
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
// Clear for next test call
mockCheckNodeCompatibility.mockClear()
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
id: 'test-pack',
name: 'Test Pack',
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
repository: 'https://github.com/user/repo',
has_registry_data: true,
latest_version: {
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0',
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
})
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.is_banned === true) {
return {
@@ -492,23 +652,37 @@ describe('PackVersionSelectorPopover', () => {
}
}
const { container } = renderComponent({
const wrapper = mountComponent({
props: { nodePack: bannedNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- icon class query not expressible via ARIA roles
const warningIcons = container.querySelectorAll(
'.icon-\\[lucide--triangle-alert\\]'
)
// Open the dropdown to see the options
const select = wrapper.find('.p-select')
if (!select.exists()) {
// Try alternative selector
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
if (selectButton.exists()) {
await selectButton.trigger('click')
}
} else {
await select.trigger('click')
}
await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows security pending warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return security pending conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
@@ -534,17 +708,16 @@ describe('PackVersionSelectorPopover', () => {
}
}
const { container } = renderComponent({
const wrapper = mountComponent({
props: { nodePack: securityPendingNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- icon class query not expressible via ARIA roles
const warningIcons = container.querySelectorAll(
'.icon-\\[lucide--triangle-alert\\]'
)
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0)
})
})