mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-28 00:07:32 +00:00
Compare commits
4 Commits
main
...
test/asset
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1afcf2e1bb | ||
|
|
aa47be0598 | ||
|
|
b4ddb07166 | ||
|
|
bbb12e35ea |
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
>
|
||||
|
||||
97
src/components/builder/DefaultViewDialogContent.vue
Normal file
97
src/components/builder/DefaultViewDialogContent.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
240
src/components/builder/useAppSetDefaultView.test.ts
Normal file
240
src/components/builder/useAppSetDefaultView.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/components/builder/useAppSetDefaultView.ts
Normal file
82
src/components/builder/useAppSetDefaultView.ts
Normal 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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -89,7 +89,6 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
inputs: [...data.inputs],
|
||||
outputs: [...data.outputs]
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user