Compare commits

..

3 Commits

Author SHA1 Message Date
Alexander Brown
aa88c99c5d Merge remote-tracking branch 'origin/main' into fix/preserve-promotion-order-9995
Amp-Thread-ID: https://ampcode.com/threads/T-019d30c2-96c1-763d-add0-c9418d97c01e
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	src/stores/promotionStore.ts
2026-03-27 12:44:14 -07:00
Alexander Brown
15ae207c6b Merge branch 'main' into fix/preserve-promotion-order-9995 2026-03-17 23:17:45 -07:00
bymyself
8005a02917 fix: preserve promotion order through visibility toggle (#9995) 2026-03-16 06:56:10 +00:00
54 changed files with 2144 additions and 3727 deletions

View File

@@ -174,10 +174,6 @@ export class AssetsSidebarTab extends SidebarTab {
super(page, 'assets')
}
get root() {
return this.page.locator('.sidebar-content-container')
}
get generatedTab() {
return this.page.getByRole('tab', { name: 'Generated' })
}
@@ -192,143 +188,12 @@ export class AssetsSidebarTab extends SidebarTab {
)
}
get searchInput() {
return this.root.getByPlaceholder(/Search Assets/i)
}
get viewSettingsButton() {
return this.root.getByLabel('View settings')
}
get listViewButton() {
return this.page.getByRole('button', { name: 'List view' })
}
get gridViewButton() {
return this.page.getByRole('button', { name: 'Grid view' })
}
get backButton() {
return this.page.getByRole('button', { name: 'Back to all assets' })
}
get copyJobIdButton() {
return this.page.getByRole('button', { name: 'Copy job ID' })
}
get previewDialog() {
return this.page.getByRole('dialog', { name: 'Gallery' })
}
get selectionCountButton() {
return this.root.getByRole('button', { name: /Assets Selected:/ })
}
get downloadSelectionButton() {
return this.page.getByRole('button', { name: 'Download' })
}
get deleteSelectionButton() {
return this.page.getByRole('button', { name: 'Delete' })
}
emptyStateTitle(title: string) {
return this.page.getByText(title)
}
previewImage(filename: string) {
return this.previewDialog.getByRole('img', { name: filename })
}
asset(name: string) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return this.root.getByRole('button', {
name: new RegExp(`^${escaped} - .* asset$`)
})
}
contextMenuAction(name: string) {
return this.page.getByRole('button', { name })
}
async showGenerated() {
await this.generatedTab.click()
}
async showImported() {
await this.importedTab.click()
}
async search(query: string) {
await this.searchInput.fill(query)
}
async switchToListView() {
await this.viewSettingsButton.click()
await this.listViewButton.click()
}
async switchToGridView() {
await this.viewSettingsButton.click()
await this.gridViewButton.click()
}
async openContextMenuForAsset(name: string) {
await this.asset(name).click({ button: 'right' })
}
async runContextMenuAction(assetName: string, actionName: string) {
await this.openContextMenuForAsset(assetName)
await this.contextMenuAction(actionName).click()
}
async openAssetPreview(name: string) {
const asset = this.asset(name)
await asset.hover()
const zoomButton = asset.getByLabel('Zoom in')
if (await zoomButton.isVisible().catch(() => false)) {
await zoomButton.click()
return
}
await asset.dblclick()
}
async openOutputFolder(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
await this.page
.getByRole('button', { name: 'Back to all assets' })
.waitFor({ state: 'visible' })
}
async toggleStack(name: string) {
await this.asset(name)
.getByRole('button', { name: 'See more outputs' })
.click()
}
async selectAssets(names: string[]) {
if (names.length === 0) {
return
}
await this.asset(names[0]).click()
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
for (const name of names.slice(1)) {
await this.asset(name).click({
modifiers: [modifier]
})
}
}
override async open() {
await super.open()
await this.root.waitFor({ state: 'visible' })
await this.generatedTab.waitFor({ state: 'visible' })
}
}

View File

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

View File

@@ -1,308 +1,112 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import type { Page, Route } from '@playwright/test'
import type {
JobDetail,
RawJobListItem
} from '../../../src/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemType, TaskOutput } from '../../../src/schemas/apiSchema'
import { JobsApiMock, type SeededJob } from './JobsApiMock'
import { getMimeType } from './mimeTypeUtil'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
const helperDir = path.dirname(fileURLToPath(import.meta.url))
type SeededAssetFile = {
filePath?: string
contentType?: string
textContent?: string
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
export type ImportedAssetSeed = {
name: string
filePath?: string
contentType?: string
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
export type GeneratedAssetOutputSeed = {
filename: string
displayName?: string
filePath?: string
contentType?: string
mediaType?: 'images' | 'video' | 'audio'
subfolder?: string
type?: ResultItemType
}
export type GeneratedJobSeed = {
jobId: string
outputs: [GeneratedAssetOutputSeed, ...GeneratedAssetOutputSeed[]]
createdAt?: string
createTime?: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string | null
workflow?: unknown
nodeId?: string
}
function getFixturePath(relativePath: string): string {
return path.resolve(helperDir, '../../assets', relativePath)
}
function defaultFileFor(filename: string): SeededAssetFile {
const name = filename.toLowerCase()
if (name.endsWith('.png')) {
return {
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
contentType: 'image/png'
}
}
if (name.endsWith('.webp')) {
return {
filePath: getFixturePath('example.webp'),
contentType: 'image/webp'
}
}
if (name.endsWith('.webm')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.webm'),
contentType: 'video/webm'
}
}
if (name.endsWith('.mp4')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
contentType: 'video/mp4'
}
}
if (name.endsWith('.glb')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.glb'),
contentType: 'model/gltf-binary'
}
}
if (name.endsWith('.json')) {
return {
textContent: JSON.stringify({ mocked: true }, null, 2),
contentType: 'application/json'
}
}
return {
textContent: 'mocked asset content',
contentType: getMimeType(filename)
}
}
function normalizeOutputSeed(
output: GeneratedAssetOutputSeed
): GeneratedAssetOutputSeed {
const fallback = defaultFileFor(output.filename)
return {
mediaType: 'images',
subfolder: '',
type: 'output',
...output,
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType
}
}
function buildTaskOutput(
jobSeed: GeneratedJobSeed,
outputs: GeneratedAssetOutputSeed[]
): TaskOutput {
const nodeId = jobSeed.nodeId ?? '5'
return {
[nodeId]: {
[outputs[0].mediaType ?? 'images']: outputs.map((output) => ({
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
display_name: output.displayName
}))
}
}
}
function buildSeededJob(jobSeed: GeneratedJobSeed): SeededJob {
const outputs = jobSeed.outputs.map(normalizeOutputSeed)
const preview = outputs[0]
const createTime =
jobSeed.createTime ??
new Date(jobSeed.createdAt ?? '2026-03-27T12:00:00.000Z').getTime()
const executionStartTime = jobSeed.executionStartTime ?? createTime
const executionEndTime = jobSeed.executionEndTime ?? createTime + 2_000
const listItem: RawJobListItem = {
id: jobSeed.jobId,
status: 'completed',
create_time: createTime,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
preview_output: {
filename: preview.filename,
subfolder: preview.subfolder ?? '',
type: preview.type ?? 'output',
nodeId: jobSeed.nodeId ?? '5',
mediaType: preview.mediaType ?? 'images',
display_name: preview.displayName
},
outputs_count: outputs.length,
workflow_id: jobSeed.workflowId ?? null
}
const detail: JobDetail = {
...listItem,
workflow: jobSeed.workflow,
outputs: buildTaskOutput(jobSeed, outputs),
update_time: executionEndTime
}
return { listItem, detail }
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class AssetsHelper {
private readonly jobsApiMock: JobsApiMock
private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
private generatedJobs: GeneratedJobSeed[] = []
private importedFiles: ImportedAssetSeed[] = []
private seededFiles = new Map<string, SeededAssetFile>()
private generatedJobs: RawJobListItem[] = []
private importedFiles: string[] = []
constructor(private readonly page: Page) {
this.jobsApiMock = new JobsApiMock(page)
}
constructor(private readonly page: Page) {}
generatedImage(
options: Partial<Omit<GeneratedJobSeed, 'outputs'>> & {
filename: string
displayName?: string
filePath?: string
contentType?: string
async mockOutputHistory(jobs: RawJobListItem[]): Promise<void> {
this.generatedJobs = [...jobs]
if (this.jobsRouteHandler) {
return
}
): GeneratedJobSeed {
const {
filename,
displayName,
filePath,
contentType,
jobId = `job-${filename.replace(/\W+/g, '-').toLowerCase()}`,
...rest
} = options
return {
jobId,
outputs: [
{
filename,
displayName,
filePath,
contentType,
mediaType: 'images'
}
],
...rest
}
}
this.jobsRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
importedImage(options: ImportedAssetSeed): ImportedAssetSeed {
return { ...options }
}
let filteredJobs = [...this.generatedJobs]
async workflowContainerFromFixture(
relativePath: string = 'default.json'
): Promise<GeneratedJobSeed['workflow']> {
const workflow = JSON.parse(
await readFile(getFixturePath(relativePath), 'utf-8')
)
return {
extra_data: {
extra_pnginfo: {
workflow
}
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
}
}
async seedAssets({
generated = [],
imported = []
}: {
generated?: GeneratedJobSeed[]
imported?: ImportedAssetSeed[]
}): Promise<void> {
this.generatedJobs = [...generated]
this.importedFiles = [...imported]
this.seededFiles = new Map()
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
for (const job of this.generatedJobs) {
for (const output of job.outputs) {
const fallback = defaultFileFor(output.filename)
this.seededFiles.set(output.filename, {
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType,
textContent: fallback.textContent
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
}
}
for (const asset of this.importedFiles) {
const fallback = defaultFileFor(asset.name)
this.seededFiles.set(asset.name, {
filePath: asset.filePath ?? fallback.filePath,
contentType: asset.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
await this.jobsApiMock.seedJobs(this.generatedJobs.map(buildSeededJob))
await this.ensureInputFilesRoute()
await this.ensureViewRoute()
await this.page.route(jobsListRoutePattern, this.jobsRouteHandler)
}
async mockEmptyState(): Promise<void> {
await this.seedAssets({ generated: [], imported: [] })
}
async mockInputFiles(files: string[]): Promise<void> {
this.importedFiles = [...files]
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
this.seededFiles.clear()
await this.jobsApiMock.clearMocks()
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.viewRouteHandler) {
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
this.viewRouteHandler = null
}
}
private async ensureInputFilesRoute(): Promise<void> {
if (this.inputFilesRouteHandler) {
return
}
@@ -311,51 +115,33 @@ export class AssetsHelper {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
body: JSON.stringify(this.importedFiles)
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
private async ensureViewRoute(): Promise<void> {
if (this.viewRouteHandler) {
return
async mockEmptyState(): Promise<void> {
await this.mockOutputHistory([])
await this.mockInputFiles([])
}
async clearMocks(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
if (this.jobsRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler)
this.jobsRouteHandler = null
}
this.viewRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'Missing filename' })
})
return
}
const seededFile =
this.seededFiles.get(filename) ?? defaultFileFor(filename)
if (seededFile.filePath) {
const body = await readFile(seededFile.filePath)
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body
})
return
}
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body: seededFile.textContent ?? ''
})
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
await this.page.route(viewRoutePattern, this.viewRouteHandler)
}
}

View File

@@ -1,194 +0,0 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetail,
RawJobListItem
} from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
export type SeededJob = {
listItem: RawJobListItem
detail: JobDetail
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: RawJobListItem): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
export class JobsApiMock {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private seededJobs: SeededJob[] = []
constructor(private readonly page: Page) {}
async seedJobs(jobs: SeededJob[]): Promise<void> {
this.seededJobs = [...jobs]
await this.ensureRoutesRegistered()
}
async clearMocks(): Promise<void> {
this.seededJobs = []
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = this.seededJobs.map(({ listItem }) => listItem)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = route
.request()
.url()
.split('/api/jobs/')[1]
?.split('?')[0]
const job = jobId
? this.seededJobs.find(({ listItem }) => listItem.id === jobId)
: undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const requestBody = route.request().postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.seededJobs = this.seededJobs.filter(
({ listItem }) =>
listItem.status === 'pending' || listItem.status === 'in_progress'
)
}
if (requestBody?.delete?.length) {
const deletedIds = new Set(requestBody.delete)
this.seededJobs = this.seededJobs.filter(
({ listItem }) => !deletedIds.has(listItem.id)
)
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -14,9 +14,8 @@ export class KeyboardHelper {
keyToPress: string,
locator: Locator | null = this.canvas
): Promise<void> {
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
const target = locator ?? this.page.keyboard
await target.press(`${modifier}+${keyToPress}`)
await target.press(`Control+${keyToPress}`)
await this.nextFrame()
}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

View File

@@ -1,417 +1,30 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import type { GeneratedJobSeed } from '../../fixtures/helpers/AssetsHelper'
async function openAssetsSidebar(
comfyPage: ComfyPage,
seed: Parameters<ComfyPage['assets']['seedAssets']>[0]
) {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'], {
origin: comfyPage.url
})
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.assets.seedAssets(seed)
const tab = comfyPage.menu.assetsTab
await tab.open()
return tab
}
function makeGeneratedAssets(comfyPage: ComfyPage) {
const stacked: GeneratedJobSeed = {
jobId: 'job-gallery-stack',
outputs: [
{
filename: 'gallery-main.webp',
displayName: 'Gallery Main',
mediaType: 'images'
},
{
filename: 'gallery-alt.webp',
displayName: 'Gallery Alt',
mediaType: 'images'
},
{
filename: 'gallery-detail.webp',
displayName: 'Gallery Detail',
mediaType: 'images'
}
]
}
return {
sunrise: comfyPage.assets.generatedImage({
jobId: 'job-sunrise',
filename: 'sunrise.webp',
displayName: 'Sunrise'
}),
forest: comfyPage.assets.generatedImage({
jobId: 'job-forest',
filename: 'forest.webp',
displayName: 'Forest'
}),
stacked
}
}
function makeImportedAssets(comfyPage: ComfyPage) {
return {
concept: comfyPage.assets.importedImage({
name: 'concept.png'
}),
reference: comfyPage.assets.importedImage({
name: 'reference.png'
})
}
}
async function makeWorkflowGeneratedAsset(comfyPage: ComfyPage) {
return comfyPage.assets.generatedImage({
jobId: 'job-workflow-sunrise',
filename: 'workflow-sunrise.webp',
displayName: 'Workflow Sunrise',
workflow: await comfyPage.assets.workflowContainerFromFixture()
})
}
test.describe('Assets sidebar', () => {
test.describe.configure({ timeout: 30_000 })
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockEmptyState()
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('shows empty-state copy for generated and imported tabs', async ({
test('Shows empty-state copy for generated and imported tabs', async ({
comfyPage
}) => {
const tab = await openAssetsSidebar(comfyPage, {
generated: [],
imported: []
})
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
await tab.showImported()
await tab.importedTab.click()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('shows generated and imported assets, and clears search when switching tabs', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const imported = makeImportedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest],
imported: [imported.concept, imported.reference]
})
await expect(tab.asset('Sunrise')).toBeVisible()
await expect(tab.asset('Forest')).toBeVisible()
await tab.search('Sunrise')
await expect(tab.searchInput).toHaveValue('Sunrise')
await expect(tab.asset('Sunrise')).toBeVisible()
await expect(tab.asset('Forest')).not.toBeVisible()
await tab.showImported()
await expect(tab.searchInput).toHaveValue('')
await expect(tab.asset('concept.png')).toBeVisible()
await expect(tab.asset('reference.png')).toBeVisible()
})
test('opens preview from list view and shows the media dialog', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.switchToListView()
await tab.openAssetPreview('Sunrise')
await expect(tab.previewDialog).toBeVisible()
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
await tab.previewDialog.getByLabel('Close').click()
await expect(tab.previewDialog).not.toBeVisible()
})
test('expands stacked outputs in list view', async ({ comfyPage }) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.stacked]
})
await tab.switchToListView()
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
await tab.toggleStack('Gallery Main')
await expect(tab.asset('Gallery Alt')).toBeVisible()
await expect(tab.asset('Gallery Detail')).toBeVisible()
})
test('opens folder view for multi-output assets, copies the job ID, and returns back', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.stacked]
})
await tab.openOutputFolder('Gallery Main')
await expect(tab.backButton).toBeVisible()
await expect(tab.copyJobIdButton).toBeVisible()
await expect(tab.asset('Gallery Main')).toBeVisible()
await expect(tab.asset('Gallery Alt')).toBeVisible()
await expect(tab.asset('Gallery Detail')).toBeVisible()
await tab.copyJobIdButton.click()
await expect(comfyPage.visibleToasts).toContainText('Copied')
await expect(comfyPage.visibleToasts).toContainText(
'Job ID copied to clipboard'
)
await tab.searchInput.click()
await comfyPage.clipboard.paste(tab.searchInput)
await expect(tab.searchInput).toHaveValue(generated.stacked.jobId)
await tab.backButton.click()
await expect(tab.asset('Gallery Main')).toBeVisible()
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
await expect(tab.asset('Gallery Detail')).not.toBeVisible()
})
test('shows output asset context-menu actions and can delete an asset', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest]
})
await tab.openContextMenuForAsset('Sunrise')
await expect(tab.contextMenuAction('Inspect asset')).toBeVisible()
await expect(
tab.contextMenuAction('Insert as node in workflow')
).toBeVisible()
await expect(tab.contextMenuAction('Download')).toBeVisible()
await expect(
tab.contextMenuAction('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuAction('Export workflow')).toBeVisible()
await expect(tab.contextMenuAction('Copy job ID')).toBeVisible()
await expect(tab.contextMenuAction('Delete')).toBeVisible()
await tab.contextMenuAction('Delete').click()
await comfyPage.confirmDialog.click('delete')
await expect(tab.asset('Sunrise')).not.toBeVisible()
await expect(tab.asset('Forest')).toBeVisible()
})
test('opens preview from the output asset context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.runContextMenuAction('Sunrise', 'Inspect asset')
await expect(tab.previewDialog).toBeVisible()
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
})
test('downloads an output asset from the context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.runContextMenuAction('Sunrise', 'Download')
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('Sunrise')
})
test('copies an output asset job ID from the context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.runContextMenuAction('Sunrise', 'Copy job ID')
await expect(comfyPage.visibleToasts).toContainText('Copied to clipboard')
await tab.searchInput.click()
await comfyPage.clipboard.paste(tab.searchInput)
await expect(tab.searchInput).toHaveValue(generated.sunrise.jobId)
})
test('inserts an output asset into the workflow from the context menu', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise]
})
await tab.runContextMenuAction('Sunrise', 'Insert as node in workflow')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.vueNodes.getNodeByTitle('Load Image')).toBeVisible()
})
test('opens a workflow from the output asset context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
const workflowAsset = await makeWorkflowGeneratedAsset(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [workflowAsset]
})
await tab.runContextMenuAction(
'Workflow Sunrise',
'Open as workflow in new tab'
)
await expect(comfyPage.visibleToasts).toContainText(
'Workflow opened in new tab'
)
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await expect
.poll(async () => {
return (await workflowsTab.getOpenedWorkflowNames()).some((name) =>
name.includes('workflow-sunrise')
)
})
.toBe(true)
})
test('exports a workflow from the output asset context menu', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.PromptFilename', false)
const workflowAsset = await makeWorkflowGeneratedAsset(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [workflowAsset]
})
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.runContextMenuAction('Workflow Sunrise', 'Export workflow')
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('workflow-sunrise.json')
await expect(comfyPage.visibleToasts).toContainText(
'Workflow exported successfully'
)
})
test('shows imported asset context-menu actions without output-only actions, and can insert the asset into the workflow', async ({
comfyPage
}) => {
const imported = makeImportedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
imported: [imported.concept]
})
await tab.showImported()
await tab.openContextMenuForAsset('concept.png')
await expect(
tab.contextMenuAction('Insert as node in workflow')
).toBeVisible()
await expect(tab.contextMenuAction('Download')).toBeVisible()
await expect(
tab.contextMenuAction('Open as workflow in new tab')
).toBeVisible()
await expect(tab.contextMenuAction('Export workflow')).toBeVisible()
await expect(tab.contextMenuAction('Copy job ID')).not.toBeVisible()
await expect(tab.contextMenuAction('Delete')).not.toBeVisible()
await tab.contextMenuAction('Insert as node in workflow').click()
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.vueNodes.getNodeByTitle('Load Image')).toBeVisible()
})
test('shows the selection footer, can clear the selection, and can download a selected asset', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise, generated.forest]
})
await tab.selectAssets(['Sunrise', 'Forest'])
await expect(tab.selectionCountButton).toBeVisible()
await expect(tab.selectionCountButton).toContainText('Assets Selected: 2')
await tab.selectionCountButton.click()
await expect(tab.selectionCountButton).not.toBeVisible()
await tab.selectAssets(['Sunrise'])
const downloadPromise = comfyPage.page.waitForEvent('download')
await tab.downloadSelectionButton.click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('Sunrise')
await expect(tab.selectionCountButton).not.toBeVisible()
})
test('clears the current selection when switching tabs', async ({
comfyPage
}) => {
const generated = makeGeneratedAssets(comfyPage)
const imported = makeImportedAssets(comfyPage)
const tab = await openAssetsSidebar(comfyPage, {
generated: [generated.sunrise],
imported: [imported.concept]
})
await tab.selectAssets(['Sunrise'])
await expect(tab.selectionCountButton).toBeVisible()
await tab.showImported()
await expect(tab.selectionCountButton).not.toBeVisible()
await expect(tab.asset('concept.png')).toBeVisible()
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
:aria-label="$t('mediaAsset.actions.copyJobId')"
@click="copyJobId"
>
<i class="icon-[lucide--copy] text-sm"></i>
@@ -144,16 +143,11 @@
<Button
v-if="shouldShowDeleteButton"
size="icon"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
size="icon"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
@click="handleDownloadSelected"
>
<Button size="icon" @click="handleDownloadSelected">
<i class="icon-[lucide--download] size-4" />
</Button>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -123,7 +123,6 @@
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />

View File

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

View File

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

View File

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

View File

@@ -661,6 +661,121 @@ describe(usePromotionStore, () => {
})
})
describe('ordering preservation through visibility toggle', () => {
const seed = { sourceNodeId: '10', sourceWidgetName: 'seed' }
const steps = { sourceNodeId: '11', sourceWidgetName: 'steps' }
const cfg = { sourceNodeId: '12', sourceWidgetName: 'cfg' }
const denoise = { sourceNodeId: '13', sourceWidgetName: 'denoise' }
const model = { sourceNodeId: '20', sourceWidgetName: 'model' }
it('preserves position when demoting then re-promoting', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.promote(graphA, nodeId, cfg)
store.demote(graphA, nodeId, steps)
expect(store.getPromotions(graphA, nodeId)).toEqual([seed, cfg])
store.promote(graphA, nodeId, steps)
expect(store.getPromotions(graphA, nodeId)).toEqual([seed, steps, cfg])
})
it('preserves position through multiple toggle cycles', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.promote(graphA, nodeId, cfg)
store.demote(graphA, nodeId, steps)
store.promote(graphA, nodeId, steps)
store.demote(graphA, nodeId, steps)
store.promote(graphA, nodeId, steps)
expect(store.getPromotions(graphA, nodeId)).toEqual([seed, steps, cfg])
})
it('preserves position when demoting first entry', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.promote(graphA, nodeId, cfg)
store.demote(graphA, nodeId, seed)
store.promote(graphA, nodeId, seed)
expect(store.getPromotions(graphA, nodeId)).toEqual([seed, steps, cfg])
})
it('preserves position when demoting last entry', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.promote(graphA, nodeId, cfg)
store.demote(graphA, nodeId, cfg)
store.promote(graphA, nodeId, cfg)
expect(store.getPromotions(graphA, nodeId)).toEqual([seed, steps, cfg])
})
it('appends truly new entries after all manifest entries', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.demote(graphA, nodeId, steps)
store.promote(graphA, nodeId, denoise)
expect(store.getPromotions(graphA, nodeId)).toEqual([seed, denoise])
store.promote(graphA, nodeId, steps)
expect(store.getPromotions(graphA, nodeId)).toEqual([
seed,
steps,
denoise
])
})
it('movePromotion operates on visible entries only', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.promote(graphA, nodeId, cfg)
store.demote(graphA, nodeId, steps)
store.movePromotion(graphA, nodeId, 0, 1)
expect(store.getPromotions(graphA, nodeId)).toEqual([cfg, seed])
})
it('setPromotions replaces the entire manifest including hidden entries', () => {
store.promote(graphA, nodeId, seed)
store.promote(graphA, nodeId, steps)
store.demote(graphA, nodeId, steps)
store.setPromotions(graphA, nodeId, [model])
expect(store.getPromotions(graphA, nodeId)).toEqual([model])
store.promote(graphA, nodeId, steps)
expect(store.getPromotions(graphA, nodeId)).toEqual([model, steps])
})
it('ref-counts stay correct through demote-promote cycles', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
store.promote(graphA, nodeA, seed)
store.promote(graphA, nodeB, seed)
store.demote(graphA, nodeA, seed)
expect(store.isPromotedByAny(graphA, seed)).toBe(true)
store.promote(graphA, nodeA, seed)
expect(store.isPromotedByAny(graphA, seed)).toBe(true)
store.demote(graphA, nodeA, seed)
store.demote(graphA, nodeB, seed)
expect(store.isPromotedByAny(graphA, seed)).toBe(false)
})
})
describe('graph isolation', () => {
it('isolates promotions by graph id', () => {
store.promote(graphA, nodeId, {

View File

@@ -5,7 +5,12 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface ManifestEntry extends PromotedWidgetSource {
promoted: boolean
}
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
const EMPTY_MANIFEST: readonly ManifestEntry[] = []
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
@@ -15,20 +20,20 @@ export function makePromotionEntryKey(source: PromotedWidgetSource): string {
}
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(
new Map<UUID, Map<NodeId, PromotedWidgetSource[]>>()
)
const graphManifests = ref(new Map<UUID, Map<NodeId, ManifestEntry[]>>())
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
const promotedCache = new WeakMap<
readonly ManifestEntry[],
PromotedWidgetSource[]
>()
function _getPromotionsForGraph(
graphId: UUID
): Map<NodeId, PromotedWidgetSource[]> {
const promotions = graphPromotions.value.get(graphId)
if (promotions) return promotions
function _getManifestForGraph(graphId: UUID): Map<NodeId, ManifestEntry[]> {
const manifests = graphManifests.value.get(graphId)
if (manifests) return manifests
const nextPromotions = new Map<NodeId, PromotedWidgetSource[]>()
graphPromotions.value.set(graphId, nextPromotions)
return nextPromotions
const nextManifests = new Map<NodeId, ManifestEntry[]>()
graphManifests.value.set(graphId, nextManifests)
return nextManifests
}
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
@@ -40,9 +45,42 @@ export const usePromotionStore = defineStore('promotion', () => {
return nextRefCounts
}
function _matchesEntry(
entry: PromotedWidgetSource,
source: PromotedWidgetSource
): boolean {
return (
entry.sourceNodeId === source.sourceNodeId &&
entry.sourceWidgetName === source.sourceWidgetName &&
entry.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
}
function _getPromotedEntries(
manifest: readonly ManifestEntry[]
): PromotedWidgetSource[] {
const cached = promotedCache.get(manifest)
if (cached) return cached
const promoted: PromotedWidgetSource[] = []
for (const e of manifest) {
if (!e.promoted) continue
const entry: PromotedWidgetSource = {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName
}
if (e.disambiguatingSourceNodeId)
entry.disambiguatingSourceNodeId = e.disambiguatingSourceNodeId
promoted.push(entry)
}
promotedCache.set(manifest, promoted)
return promoted
}
function _incrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
entries: readonly PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
@@ -53,27 +91,50 @@ export const usePromotionStore = defineStore('promotion', () => {
function _decrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
entries: readonly PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
} else {
refCounts.set(key, count)
}
if (count <= 0) refCounts.delete(key)
else refCounts.set(key, count)
}
}
function _commitManifest(
graphId: UUID,
subgraphNodeId: NodeId,
nextManifest: ManifestEntry[]
): void {
const manifests = _getManifestForGraph(graphId)
const prevManifest = manifests.get(subgraphNodeId) ?? EMPTY_MANIFEST
if (prevManifest === nextManifest) return
_decrementKeys(graphId, _getPromotedEntries(prevManifest))
_incrementKeys(graphId, _getPromotedEntries(nextManifest))
if (nextManifest.length === 0) manifests.delete(subgraphNodeId)
else manifests.set(subgraphNodeId, nextManifest)
}
function _updateManifest(
graphId: UUID,
subgraphNodeId: NodeId,
updater: (manifest: readonly ManifestEntry[]) => ManifestEntry[]
): void {
const manifests = _getManifestForGraph(graphId)
const prevManifest = manifests.get(subgraphNodeId) ?? EMPTY_MANIFEST
_commitManifest(graphId, subgraphNodeId, updater(prevManifest))
}
function getPromotionsRef(
graphId: UUID,
subgraphNodeId: NodeId
): PromotedWidgetSource[] {
return (
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
)
const manifest = _getManifestForGraph(graphId).get(subgraphNodeId)
return manifest ? _getPromotedEntries(manifest) : EMPTY_PROMOTIONS
}
function getPromotions(
@@ -88,12 +149,9 @@ export const usePromotionStore = defineStore('promotion', () => {
subgraphNodeId: NodeId,
source: PromotedWidgetSource
): boolean {
return getPromotionsRef(graphId, subgraphNodeId).some(
(e) =>
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
const manifest = _getManifestForGraph(graphId).get(subgraphNodeId)
if (!manifest) return false
return manifest.some((e) => e.promoted && _matchesEntry(e, source))
}
function isPromotedByAny(
@@ -109,17 +167,11 @@ export const usePromotionStore = defineStore('promotion', () => {
subgraphNodeId: NodeId,
entries: PromotedWidgetSource[]
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
_decrementKeys(graphId, oldEntries)
_incrementKeys(graphId, entries)
if (entries.length === 0) {
promotions.delete(subgraphNodeId)
} else {
promotions.set(subgraphNodeId, [...entries])
}
_commitManifest(
graphId,
subgraphNodeId,
entries.map((e) => ({ ...e, promoted: true }))
)
}
function promote(
@@ -127,16 +179,17 @@ export const usePromotionStore = defineStore('promotion', () => {
subgraphNodeId: NodeId,
source: PromotedWidgetSource
): void {
if (isPromoted(graphId, subgraphNodeId, source)) return
_updateManifest(graphId, subgraphNodeId, (manifest) => {
const index = manifest.findIndex((e) => _matchesEntry(e, source))
const entries = getPromotionsRef(graphId, subgraphNodeId)
const entry: PromotedWidgetSource = {
sourceNodeId: source.sourceNodeId,
sourceWidgetName: source.sourceWidgetName
}
if (source.disambiguatingSourceNodeId)
entry.disambiguatingSourceNodeId = source.disambiguatingSourceNodeId
setPromotions(graphId, subgraphNodeId, [...entries, entry])
if (index === -1) return [...manifest, { ...source, promoted: true }]
if (manifest[index].promoted) return manifest as ManifestEntry[]
const next = [...manifest]
next[index] = { ...next[index], promoted: true }
return next
})
}
function demote(
@@ -144,19 +197,17 @@ export const usePromotionStore = defineStore('promotion', () => {
subgraphNodeId: NodeId,
source: PromotedWidgetSource
): void {
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(
graphId,
subgraphNodeId,
entries.filter(
(e) =>
!(
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
_updateManifest(graphId, subgraphNodeId, (manifest) => {
const index = manifest.findIndex(
(e) => e.promoted && _matchesEntry(e, source)
)
)
if (index === -1) return manifest as ManifestEntry[]
const next = [...manifest]
next[index] = { ...next[index], promoted: false }
return next
})
}
function movePromotion(
@@ -165,28 +216,35 @@ export const usePromotionStore = defineStore('promotion', () => {
fromIndex: number,
toIndex: number
): void {
const promotions = _getPromotionsForGraph(graphId)
const currentEntries = promotions.get(subgraphNodeId)
if (!currentEntries?.length) return
_updateManifest(graphId, subgraphNodeId, (manifest) => {
const promotedIndices: number[] = []
for (let i = 0; i < manifest.length; i++) {
if (manifest[i].promoted) promotedIndices.push(i)
}
const entries = [...currentEntries]
if (
fromIndex < 0 ||
fromIndex >= entries.length ||
toIndex < 0 ||
toIndex >= entries.length ||
fromIndex === toIndex
)
return
if (
fromIndex < 0 ||
fromIndex >= promotedIndices.length ||
toIndex < 0 ||
toIndex >= promotedIndices.length ||
fromIndex === toIndex
)
return manifest as ManifestEntry[]
const [entry] = entries.splice(fromIndex, 1)
entries.splice(toIndex, 0, entry)
const promotedEntries = promotedIndices.map((i) => manifest[i])
const [moved] = promotedEntries.splice(fromIndex, 1)
promotedEntries.splice(toIndex, 0, moved)
promotions.set(subgraphNodeId, entries)
const next = [...manifest]
promotedIndices.forEach((manifestIndex, i) => {
next[manifestIndex] = promotedEntries[i]
})
return next
})
}
function clearGraph(graphId: UUID): void {
graphPromotions.value.delete(graphId)
graphManifests.value.delete(graphId)
graphRefCounts.value.delete(graphId)
}

View File

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

View File

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

View File

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

View File

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