Compare commits

..

7 Commits

Author SHA1 Message Date
bymyself
241cd97109 refactor: replace as never casts with typed fixtures in Load3d tests
Address CodeRabbit review feedback: use CameraState type import,
THREE.Vector3 constructors, and as unknown as THREE.Object3D casts
instead of as never to maintain type safety in test fixtures.
2026-04-10 18:30:26 -07:00
GitHub Action
b8d175dd6d [automated] Apply ESLint and Oxfmt fixes 2026-04-11 01:00:44 +00:00
bymyself
a62d8e7b69 test: add unit tests for Load3d 3D viewer facade 2026-04-10 17:57:02 -07:00
Alexander Brown
5c14badc42 fix(vite): hide git rev-parse window on Windows (#11144)
## Summary

Add `windowsHide: true` to the `execSync('git rev-parse HEAD')` call in
`vite.config.mts` to prevent a console window from flashing on Windows
during builds.

## Changes

- **What**: Pass `windowsHide: true` option to `execSync` when fetching
the git commit hash at build time. This suppresses the transient cmd.exe
popup that appears on Windows.

## Review Focus

Minimal, single-option change. `windowsHide` is a Node.js built-in
option for `child_process` methods — no-op on non-Windows platforms.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11144-fix-vite-hide-git-rev-parse-window-on-Windows-33e6d73d365081ed9a14da5f47ccac4d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 23:42:26 +00:00
pythongosssss
82fb8ce658 test: App mode - Execution tests (#10801)
## Summary

Adds tests that simulate the execution flow and output feed

## Changes

- **What**: 
- Add ExecutionHelper for mocking network activity
- Refactor ws fixture to use Playwright websocket helper instead of
patching window
- 

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10801-test-App-mode-Execution-tests-3356d73d365081e4acf0c34378600031)
by [Unito](https://www.unito.io)
2026-04-10 23:31:56 +00:00
Christian Byrne
c3e823e55b fix: use standard size-4 for blueprint action icons (#10992)
## Summary

Fix undersized delete and edit icons on user blueprint items in the node
library sidebar.

## Changes

- **What**: Changed blueprint action icons (trash, edit) from `size-3.5`
(14px) to `size-4` (16px), matching the standard icon size used across
the codebase.

## Review Focus

Trivial sizing fix — `size-4` is the codebase-wide convention for
iconify icons in buttons, and what the button base styles default SVGs
to.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10992-fix-use-standard-size-4-for-blueprint-action-icons-33d6d73d365081be8c65f9e2a7b1d6ec)
by [Unito](https://www.unito.io)
2026-04-10 23:04:17 +00:00
pythongosssss
ebc9025de5 fix/feat: App mode - Persist user resized widget heights (#10993)
## Summary

Saves the user sized textarea/image dropzone elements to the linearData
in the workflow.

## Changes

- **What**: 
- Adds a 3rd element to the linearData input tuple for configuration
data
- Add appmode widget resize composable for persisting resizes
- Tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10993-fix-feat-App-mode-Persist-user-resized-widget-heights-33d6d73d36508144b700c6bfcbfa5b3c)
by [Unito](https://www.unito.io)
2026-04-10 22:24:46 +00:00
39 changed files with 2214 additions and 765 deletions

View File

@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

@@ -17,7 +17,7 @@ const features = computed(() => [
<div class="mx-auto max-w-3xl px-6 text-center">
<!-- Badge -->
<span
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs tracking-widest text-brand-yellow uppercase"
>
{{ t('academy.badge', locale) }}
</span>

View File

@@ -40,7 +40,7 @@ const steps = computed(() => [
<!-- Connecting line between steps (desktop only) -->
<div
v-if="index < steps.length - 1"
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
class="absolute top-8 right-0 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
/>
<div class="relative">

View File

@@ -31,11 +31,11 @@ const ctaButtons = computed(() => [
<div class="flex w-full items-center justify-center md:w-[55%]">
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
<div
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
class="size-64 rounded-full border-40 border-brand-yellow md:h-112 md:w-md md:border-64 lg:h-144 lg:w-xl lg:border-80"
>
<!-- Gap on the right side to form "C" shape -->
<div
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
class="absolute top-1/2 right-0 h-32 w-24 translate-x-1/2 -translate-y-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
/>
</div>
</div>
@@ -44,7 +44,7 @@ const ctaButtons = computed(() => [
<!-- Right: Text content -->
<div class="flex w-full flex-col items-start md:w-[45%]">
<h1
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
class="text-5xl/tight font-bold tracking-tight text-white md:text-6xl lg:text-7xl"
>
{{ t('hero.headline', locale) }}
</h1>

View File

@@ -17,7 +17,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('manifesto.heading', locale) }}
</h2>
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
<p class="mx-auto mt-6 max-w-2xl text-lg/relaxed text-smoke-700">
{{ t('manifesto.body', locale) }}
</p>

View File

@@ -33,11 +33,11 @@ const features = computed(() => [
<div class="flex flex-col items-center gap-4">
<!-- Play button triangle -->
<div
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
class="flex size-16 items-center justify-center rounded-full border-2 border-white/20"
aria-hidden="true"
>
<div
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
class="ml-1 size-0 border-y-8 border-l-14 border-y-transparent border-l-white"
/>
</div>
<p class="text-sm text-smoke-700">
@@ -54,7 +54,7 @@ const features = computed(() => [
class="flex items-center gap-2"
>
<span
class="h-2 w-2 rounded-full bg-brand-yellow"
class="size-2 rounded-full bg-brand-yellow"
aria-hidden="true"
/>
<span class="text-sm text-smoke-700">{{ feature }}</span>

View File

@@ -32,7 +32,7 @@ const metrics = computed(() => [
<div class="mx-auto max-w-7xl px-6">
<!-- Heading -->
<p
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
class="text-center text-xs font-medium tracking-widest text-smoke-700 uppercase"
>
{{ t('social.heading', locale) }}
</p>

View File

@@ -90,7 +90,7 @@ const filteredTestimonials = computed(() => {
:key="testimonial.name"
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
>
<blockquote class="text-base italic text-white">
<blockquote class="text-base text-white italic">
&ldquo;{{ testimonial.quote }}&rdquo;
</blockquote>

View File

@@ -24,12 +24,12 @@ const activeCategory = ref(0)
<!-- Left placeholder image (desktop only) -->
<div class="hidden flex-1 lg:block">
<div
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
class="aspect-2/3 rounded-full border border-white/10 bg-charcoal-600"
/>
</div>
<!-- Center content -->
<div class="flex flex-col items-center text-center lg:flex-[2]">
<div class="flex flex-col items-center text-center lg:flex-2">
<h2 class="text-3xl font-bold text-white">
{{ t('useCase.heading', locale) }}
</h2>
@@ -70,7 +70,7 @@ const activeCategory = ref(0)
<!-- Right placeholder image (desktop only) -->
<div class="hidden flex-1 lg:block">
<div
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
class="aspect-2/3 rounded-3xl border border-white/10 bg-charcoal-600"
/>
</div>
</div>

View File

@@ -53,7 +53,7 @@ const pillars = computed(() => [
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
class="flex size-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
>
{{ pillar.icon }}
</div>

View File

@@ -0,0 +1,97 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '../selectors'
const ids = TestIds.outputHistory
export class OutputHistoryComponent {
constructor(private readonly page: Page) {}
get outputs(): Locator {
return this.page.getByTestId(ids.outputs)
}
get welcome(): Locator {
return this.page.getByTestId(ids.welcome)
}
get outputInfo(): Locator {
return this.page.getByTestId(ids.outputInfo)
}
get activeQueue(): Locator {
return this.page.getByTestId(ids.activeQueue)
}
get queueBadge(): Locator {
return this.page.getByTestId(ids.queueBadge)
}
get inProgressItems(): Locator {
return this.page.getByTestId(ids.inProgressItem)
}
get historyItems(): Locator {
return this.page.getByTestId(ids.historyItem)
}
get skeletons(): Locator {
return this.page.getByTestId(ids.skeleton)
}
get latentPreviews(): Locator {
return this.page.getByTestId(ids.latentPreview)
}
get imageOutputs(): Locator {
return this.page.getByTestId(ids.imageOutput)
}
get videoOutputs(): Locator {
return this.page.getByTestId(ids.videoOutput)
}
/** The currently selected (checked) in-progress item. */
get selectedInProgressItem(): Locator {
return this.page.locator(
`[data-testid="${ids.inProgressItem}"][data-state="checked"]`
)
}
/** The currently selected (checked) history item. */
get selectedHistoryItem(): Locator {
return this.page.locator(
`[data-testid="${ids.historyItem}"][data-state="checked"]`
)
}
/** The header-level progress bar. */
get headerProgressBar(): Locator {
return this.page.getByTestId(ids.headerProgressBar)
}
/** The in-progress item's progress bar (inside the thumbnail). */
get itemProgressBar(): Locator {
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
}
/** Overall progress in the header bar. */
get headerOverallProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the header bar. */
get headerNodeProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressNode)
}
/** Overall progress in the in-progress item bar. */
get itemOverallProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the in-progress item bar. */
get itemNodeProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressNode)
}
}

View File

@@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -14,6 +15,7 @@ export class AppModeHelper {
readonly footer: BuilderFooterHelper
readonly saveAs: BuilderSaveAsHelper
readonly select: BuilderSelectHelper
readonly outputHistory: OutputHistoryComponent
readonly widgets: AppModeWidgetHelper
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
@@ -35,12 +37,15 @@ export class AppModeHelper {
public readonly backToWorkflowButton: Locator
/** The "Load template" button shown when no nodes exist. */
public readonly loadTemplateButton: Locator
/** The cancel button for an in-progress run in the output history. */
public readonly cancelRunButton: Locator
constructor(private readonly comfyPage: ComfyPage) {
this.steps = new BuilderStepsHelper(comfyPage)
this.footer = new BuilderFooterHelper(comfyPage)
this.saveAs = new BuilderSaveAsHelper(comfyPage)
this.select = new BuilderSelectHelper(comfyPage)
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
@@ -67,6 +72,9 @@ export class AppModeHelper {
this.loadTemplateButton = this.page.getByTestId(
TestIds.appMode.loadTemplate
)
this.cancelRunButton = this.page.getByTestId(
TestIds.outputHistory.cancelRun
)
}
private get page(): Page {

View File

@@ -0,0 +1,211 @@
import type { WebSocketRoute } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '../ComfyPage'
import { createMockJob } from './AssetsHelper'
/**
* Helper for simulating prompt execution in e2e tests.
*/
export class ExecutionHelper {
private jobCounter = 0
private readonly completedJobs: RawJobListItem[] = []
private readonly page: ComfyPage['page']
private readonly command: ComfyPage['command']
private readonly assets: ComfyPage['assets']
constructor(
comfyPage: ComfyPage,
private readonly ws: WebSocketRoute
) {
this.page = comfyPage.page
this.command = comfyPage.command
this.assets = comfyPage.assets
}
/**
* Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return
* the synthetic job ID.
*
* The app receives a valid PromptResponse so storeJob() fires
* and registers the job against the active workflow path.
*/
async run(): Promise<string> {
const jobId = `test-job-${++this.jobCounter}`
let fulfilled!: () => void
const prompted = new Promise<void>((r) => {
fulfilled = r
})
await this.page.route(
'**/api/prompt',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: jobId,
node_errors: {}
})
})
fulfilled()
},
{ times: 1 }
)
await this.command.executeCommand('Comfy.QueuePrompt')
await prompted
return jobId
}
/**
* Send a binary `b_preview_with_metadata` WS message (type 4).
* Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL.
*/
latentPreview(jobId: string, nodeId: string): void {
const metadata = JSON.stringify({
node_id: nodeId,
display_node_id: nodeId,
parent_node_id: nodeId,
real_node_id: nodeId,
prompt_id: jobId,
image_type: 'image/png'
})
const metadataBytes = new TextEncoder().encode(metadata)
// 1x1 red PNG
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==',
'base64'
)
// Binary format: [type:uint32][metadataLength:uint32][metadata][imageData]
const buf = new ArrayBuffer(8 + metadataBytes.length + png.length)
const view = new DataView(buf)
view.setUint32(0, 4) // type 4 = PREVIEW_IMAGE_WITH_METADATA
view.setUint32(4, metadataBytes.length)
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
this.ws.send(Buffer.from(buf))
}
/** Send `execution_start` WS event. */
executionStart(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_start',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `executing` WS event to signal which node is currently running. */
executing(jobId: string, nodeId: string | null): void {
this.ws.send(
JSON.stringify({
type: 'executing',
data: { prompt_id: jobId, node: nodeId }
})
)
}
/** Send `executed` WS event with node output. */
executed(
jobId: string,
nodeId: string,
output: Record<string, unknown>
): void {
this.ws.send(
JSON.stringify({
type: 'executed',
data: {
prompt_id: jobId,
node: nodeId,
display_node: nodeId,
output
}
})
)
}
/** Send `execution_success` WS event. */
executionSuccess(jobId: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_success',
data: { prompt_id: jobId, timestamp: Date.now() }
})
)
}
/** Send `execution_error` WS event. */
executionError(jobId: string, nodeId: string, message: string): void {
this.ws.send(
JSON.stringify({
type: 'execution_error',
data: {
prompt_id: jobId,
timestamp: Date.now(),
node_id: nodeId,
node_type: 'Unknown',
exception_message: message,
exception_type: 'RuntimeError',
traceback: []
}
})
)
}
/** Send `progress` WS event. */
progress(jobId: string, nodeId: string, value: number, max: number): void {
this.ws.send(
JSON.stringify({
type: 'progress',
data: { prompt_id: jobId, node: nodeId, value, max }
})
)
}
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
*
* Requires an {@link AssetsHelper} to be passed in the constructor.
*/
async completeWithHistory(
jobId: string,
nodeId: string,
filename: string
): Promise<void> {
this.completedJobs.push(
createMockJob({
id: jobId,
preview_output: {
filename,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
})
)
await this.assets.mockOutputHistory(this.completedJobs)
this.executionSuccess(jobId)
// Trigger queue/history refresh
this.status(0)
}
/** Send `status` WS event to update queue count. */
status(queueRemaining: number): void {
this.ws.send(
JSON.stringify({
type: 'status',
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
})
)
}
}

View File

@@ -130,6 +130,24 @@ export const TestIds = {
outputPlaceholder: 'builder-output-placeholder',
connectOutputPopover: 'builder-connect-output-popover'
},
outputHistory: {
outputs: 'linear-outputs',
welcome: 'linear-welcome',
outputInfo: 'linear-output-info',
activeQueue: 'linear-job',
queueBadge: 'linear-job-badge',
inProgressItem: 'linear-in-progress-item',
historyItem: 'linear-history-item',
skeleton: 'linear-skeleton',
latentPreview: 'linear-latent-preview',
imageOutput: 'linear-image-output',
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run',
headerProgressBar: 'linear-header-progress-bar',
itemProgressBar: 'linear-item-progress-bar',
progressOverall: 'linear-progress-overall',
progressNode: 'linear-progress-node'
},
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
@@ -180,6 +198,7 @@ export type TestIdValue =
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.outputHistory)[keyof typeof TestIds.outputHistory]
| (typeof TestIds.appMode)[keyof typeof TestIds.appMode]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<

View File

@@ -1,53 +1,31 @@
import { test as base } from '@playwright/test'
import type { WebSocketRoute } from '@playwright/test'
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
getWebSocket: () => Promise<WebSocketRoute>
}>({
ws: [
async ({ page }, use) => {
// Each time a page loads, to catch navigations
page.on('load', async () => {
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = (window.__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}
getWebSocket: [
async ({ context }, use) => {
let latest: WebSocketRoute | undefined
let resolve: ((ws: WebSocketRoute) => void) | undefined
await context.routeWebSocket(/\/ws/, (ws) => {
const server = ws.connectToServer()
server.onMessage((message) => {
ws.send(message)
})
latest = ws
resolve?.(ws)
})
await use({
async trigger(data, url) {
// Trigger a websocket event on the page
await page.evaluate(
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.href)
u.hash = ''
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = window.__ws__![url]
ws.dispatchEvent(
new MessageEvent('message', {
data
})
)
},
[JSON.stringify(data), url]
)
}
await use(() => {
if (latest) return Promise.resolve(latest)
return new Promise<WebSocketRoute>((r) => {
resolve = r
})
})
},
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
{ auto: true }
]
})

View File

@@ -1,7 +1,6 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '@/schemas/apiSchema'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type { WorkspaceStore } from '@e2e/types/globals'
@@ -18,8 +17,10 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
*/
test('Does not auto-queue multiple changes at a time', async ({
comfyPage,
ws
getWebSocket
}) => {
const ws = await getWebSocket()
// Enable change auto-queue mode
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
await expect.poll(() => queueOpts.getMode()).toBe('disabled')
@@ -62,17 +63,19 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
const triggerStatus = (queueSize: number) => {
ws.send(
JSON.stringify({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
}
}
}
}
} as StatusWsMessage)
})
)
}
// Extract the width from the queue response
@@ -104,8 +107,8 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
).toBe(1)
// Trigger a status update so auto-queue re-runs
await triggerStatus(1)
await triggerStatus(0)
triggerStatus(1)
triggerStatus(0)
// Ensure the queued width is the last queued value
expect(

View File

@@ -0,0 +1,413 @@
import type { WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { comfyPageFixture, comfyExpect as expect } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)
// Node IDs from the default workflow (browser_tests/assets/default.json, 7 nodes)
const SAVE_IMAGE_NODE = '9'
const KSAMPLER_NODE = '3'
const ALL_NODE_IDS = ['4', '6', '7', '5', KSAMPLER_NODE, '8', SAVE_IMAGE_NODE]
/** Queue a prompt, intercept it, and send execution_start. */
async function startExecution(
comfyPage: ComfyPage,
ws: WebSocketRoute,
exec?: ExecutionHelper
) {
exec ??= new ExecutionHelper(comfyPage, ws)
const jobId = await exec.run()
// Allow storeJob() to complete before sending WS events
await comfyPage.nextFrame()
exec.executionStart(jobId)
return { exec, jobId }
}
function imageOutput(...filenames: string[]) {
return {
images: filenames.map((filename) => ({
filename,
subfolder: '',
type: 'output'
}))
}
}
test.describe('Output History', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await comfyPage.nextFrame()
})
test('Skeleton appears on execution start', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
})
test('Latent preview replaces skeleton', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Image output replaces skeleton on executed', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('test_output.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
})
test('Multiple outputs from single execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput('output_001.png', 'output_002.png', 'output_003.png')
)
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(3)
})
test('Video output renders video element', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executed(jobId, SAVE_IMAGE_NODE, {
gifs: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
})
await expect(
comfyPage.appMode.outputHistory.videoOutputs.first()
).toBeVisible()
})
test('Cancel button sends interrupt during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const job: RawJobListItem = {
id: jobId,
status: 'in_progress',
create_time: Date.now() / 1000,
priority: 0
}
await comfyPage.page.route(
/\/api\/jobs\?status=in_progress/,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: [job],
pagination: { offset: 0, limit: 200, total: 1, has_more: false }
})
})
},
{ times: 1 }
)
// Trigger queue refresh
exec.status(1)
await comfyPage.nextFrame()
await expect(comfyPage.appMode.cancelRunButton).toBeVisible()
await comfyPage.page.route('**/interrupt', (route) =>
route.fulfill({ status: 200 })
)
const interruptRequest = comfyPage.page.waitForRequest('**/interrupt')
await comfyPage.appMode.cancelRunButton.click()
await interruptRequest
})
test('Full execution lifecycle cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton appears
await expect(
comfyPage.appMode.outputHistory.skeletons.first()
).toBeVisible()
// Latent preview replaces skeleton
exec.latentPreview(jobId, SAVE_IMAGE_NODE)
await expect(
comfyPage.appMode.outputHistory.latentPreviews.first()
).toBeVisible()
// Image output replaces latent
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('lifecycle_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
// Job completes with history mock - in-progress items fully resolved
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'lifecycle_out.png')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
// Output now appears as a history item
await expect(
comfyPage.appMode.outputHistory.historyItems.first()
).toBeVisible()
})
test('Auto-selection follows latest in-progress item', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Skeleton is auto-selected
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toBeVisible()
// First image is auto-selected
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
// Second image arrives - selection auto-follows without user click
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /second\.png/)
})
test('Clicking item breaks auto-follow during execution', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
// Send first image
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('first.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(1)
// Click the first image to break auto-follow
await comfyPage.appMode.outputHistory.inProgressItems.first().click()
// Send second image - selection should NOT move to it
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('second.png'))
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(2)
// The first item should still be selected (not auto-followed to second)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem
).toHaveCount(1)
await expect(
comfyPage.appMode.outputHistory.selectedInProgressItem.getByTestId(
'linear-image-output'
)
).toHaveAttribute('src', /first\.png/)
})
test('Non-output node executed events are filtered', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
// KSampler is not an output node - should be filtered
exec.executed(jobId, KSAMPLER_NODE, imageOutput('ksampler_out.png'))
await comfyPage.nextFrame()
// KSampler output should not create image outputs
await expect(comfyPage.appMode.outputHistory.imageOutputs).toHaveCount(0)
// Now send from the actual output node (SaveImage)
exec.executed(jobId, SAVE_IMAGE_NODE, imageOutput('save_image_out.png'))
await expect(
comfyPage.appMode.outputHistory.imageOutputs.first()
).toBeVisible()
})
test('In-progress items are outside the scrollable area', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
// Complete one execution with 100 image outputs
const { exec, jobId } = await startExecution(comfyPage, ws)
exec.executed(
jobId,
SAVE_IMAGE_NODE,
imageOutput(
...Array.from(
{ length: 100 },
(_, i) => `image_${String(i).padStart(3, '0')}.png`
)
)
)
await exec.completeWithHistory(jobId, SAVE_IMAGE_NODE, 'image_000.png')
await expect(comfyPage.appMode.outputHistory.historyItems).toHaveCount(100)
// First history item is visible before scrolling
const firstItem = comfyPage.appMode.outputHistory.historyItems.first()
await expect(firstItem).toBeInViewport()
// Scroll the history feed all the way to the right
await comfyPage.appMode.outputHistory.outputs.evaluate((el) => {
el.scrollLeft = el.scrollWidth
})
// First history item is now off-screen
await expect(firstItem).not.toBeInViewport()
// Start a new execution to get an in-progress item
await startExecution(comfyPage, ws, exec)
// In-progress item is visible despite scrolling
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeInViewport()
})
test('Execution error cleans up in-progress items', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
await expect(
comfyPage.appMode.outputHistory.inProgressItems.first()
).toBeVisible()
exec.executionError(jobId, KSAMPLER_NODE, 'Test error')
await expect(comfyPage.appMode.outputHistory.inProgressItems).toHaveCount(0)
})
test('Progress bars update for both node and overall progress', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const {
inProgressItems,
headerOverallProgress,
headerNodeProgress,
itemOverallProgress,
itemNodeProgress
} = comfyPage.appMode.outputHistory
await expect(inProgressItems.first()).toBeVisible()
// Initially both bars are at 0%
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler starts executing - node progress at 50%
exec.executing(jobId, KSAMPLER_NODE)
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
// Overall still 0% - no nodes completed yet
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
// KSampler finishes - overall advances (1 of 7 nodes)
exec.executed(jobId, KSAMPLER_NODE, {})
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
await expect(headerOverallProgress).toHaveAttribute('style', pct)
await expect(itemOverallProgress).toHaveAttribute('style', pct)
// Node progress reaches 100%
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
// Complete remaining nodes - overall reaches 100%
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
for (const nodeId of remainingNodes) {
exec.executing(jobId, nodeId)
exec.executed(jobId, nodeId, {})
}
await expect(headerOverallProgress).toHaveAttribute(
'style',
/width:\s*100%/
)
await expect(itemOverallProgress).toHaveAttribute('style', /width:\s*100%/)
})
})

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
@@ -8,7 +10,7 @@ import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
@@ -28,6 +30,9 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeId: NodeId
widgetName: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
@@ -44,6 +49,11 @@ const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing(
(nodeId, widgetName, config) =>
appModeStore.updateInputConfig(nodeId, widgetName, config)
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
@@ -61,7 +71,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
@@ -90,6 +100,9 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: `${nodeId}:${widgetName}`,
nodeId,
widgetName,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
@@ -157,7 +170,14 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, nodeData, action } in mappedSelections"
v-for="{
key,
nodeId,
widgetName,
persistedHeight,
nodeData,
action
} in mappedSelections"
:key
:class="
cn(
@@ -222,8 +242,20 @@ defineExpose({ handleDragDrop })
</Popover>
</div>
<div
:class="builderMode && 'pointer-events-none'"
:style="
persistedHeight
? { '--persisted-height': `${persistedHeight}px` }
: undefined
"
:class="
cn(
builderMode && 'pointer-events-none',
persistedHeight &&
'**:data-[slot=drop-zone-indicator]:h-(--persisted-height) [&_textarea]:h-(--persisted-height)'
)
"
:inert="builderMode || undefined"
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
>
<DropZone
:on-drag-over="nodeData.onDragOver"

View File

@@ -0,0 +1,210 @@
import { describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
function setHeight(el: HTMLElement, height: number) {
Object.defineProperty(el, 'offsetHeight', {
value: height,
configurable: true
})
}
function wrapWithTextarea(initialHeight = 100): {
wrapper: HTMLDivElement
textarea: HTMLTextAreaElement
} {
const wrapper = document.createElement('div')
const textarea = document.createElement('textarea')
wrapper.appendChild(textarea)
document.body.appendChild(wrapper)
setHeight(textarea, initialHeight)
return { wrapper, textarea }
}
describe('useAppModeWidgetResizing', () => {
function setup() {
const onResize =
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
const { onPointerDown } = useAppModeWidgetResizing(onResize)
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
{ capture: true }
)
}
return { onResize, bind }
}
it('persists height when textarea is resized via drag', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
})
it('does not persist when no height change occurs (e.g. a click)', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).not.toHaveBeenCalled()
})
it('persists once per drag gesture; stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
})
it('ignores pointerdown on non-resizable targets (label, button, popover)', () => {
const { bind, onResize } = setup()
const wrapper = document.createElement('div')
const button = document.createElement('button')
wrapper.appendChild(button)
document.body.appendChild(wrapper)
bind(wrapper, 1 as NodeId, 'prompt')
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).not.toHaveBeenCalled()
})
it('persists when target is a descendant of the drop-zone-indicator', () => {
const { bind, onResize } = setup()
const wrapper = document.createElement('div')
const indicator = document.createElement('div')
indicator.setAttribute('data-slot', 'drop-zone-indicator')
const inner = document.createElement('span')
indicator.appendChild(inner)
wrapper.appendChild(indicator)
document.body.appendChild(wrapper)
setHeight(indicator, 100)
bind(wrapper, 1 as NodeId, 'image')
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(indicator, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
})
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
const { bind, onResize } = setup()
const first = wrapWithTextarea()
const second = wrapWithTextarea()
bind(first.wrapper, 1 as NodeId, 'prompt')
bind(second.wrapper, 2 as NodeId, 'other')
first.textarea.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
)
setHeight(first.textarea, 250)
second.textarea.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
)
setHeight(second.textarea, 300)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
})
it('treats pointercancel as the end of a gesture and persists the new height', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointercancel'))
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
})
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointercancel'))
setHeight(textarea, 400)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
})
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
const onResize =
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
const scope = effectScope()
const { onPointerDown } = scope.run(() =>
useAppModeWidgetResizing(onResize)
)!
const { wrapper, textarea } = wrapWithTextarea()
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
{ capture: true }
)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
scope.stop()
window.dispatchEvent(new PointerEvent('pointerup'))
window.dispatchEvent(new PointerEvent('pointercancel'))
expect(onResize).not.toHaveBeenCalled()
})
it('does not match a resizable that is an ancestor of the wrapper', () => {
const { bind, onResize } = setup()
// An unrelated drop-zone-indicator outside the wrapper would otherwise be
// returned by target.closest(...) walking up the tree.
const outerIndicator = document.createElement('div')
outerIndicator.setAttribute('data-slot', 'drop-zone-indicator')
const wrapper = document.createElement('div')
const inner = document.createElement('span')
wrapper.appendChild(inner)
outerIndicator.appendChild(wrapper)
document.body.appendChild(outerIndicator)
setHeight(outerIndicator, 100)
bind(wrapper, 1 as NodeId, 'prompt')
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(outerIndicator, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,55 @@
import { onScopeDispose } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
export function useAppModeWidgetResizing(
onResize: (
nodeId: NodeId,
widgetName: string,
config: InputWidgetConfig
) => void
) {
let pendingHandler: (() => void) | null = null
function clearPendingHandler() {
if (!pendingHandler) return
window.removeEventListener('pointerup', pendingHandler)
window.removeEventListener('pointercancel', pendingHandler)
pendingHandler = null
}
onScopeDispose(clearPendingHandler)
function onPointerDown(
nodeId: NodeId,
widgetName: string,
event: PointerEvent
) {
const wrapper = event.currentTarget
const target = event.target
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
return
const resizable = target.closest<HTMLElement>(RESIZABLE_SELECTOR)
if (!resizable || !wrapper.contains(resizable)) return
clearPendingHandler()
const startHeight = resizable.offsetHeight
const handler = () => {
window.removeEventListener('pointerup', handler)
window.removeEventListener('pointercancel', handler)
pendingHandler = null
const height = resizable.offsetHeight
if (height === startHeight) return
onResize(nodeId, widgetName, { height })
}
pendingHandler = handler
window.addEventListener('pointerup', handler)
window.addEventListener('pointercancel', handler)
}
return { onPointerDown }
}

View File

@@ -32,21 +32,14 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] text-xs" />
<i class="icon-[lucide--trash-2]" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
</button>
</div>
</div>
@@ -115,7 +108,7 @@ const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -25,7 +25,7 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] size-3.5" />
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="muted-textonly"
@@ -33,7 +33,7 @@
:aria-label="$t('g.edit')"
@click.stop="editBlueprint"
>
<i class="icon-[lucide--square-pen] size-3.5" />
<i class="icon-[lucide--square-pen] size-4" />
</Button>
</template>
<template v-else #actions>

View File

@@ -1,668 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
vi.mock('@vueuse/core', () => ({
useResizeObserver: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: vi.fn()
}))
vi.mock('@/stores/nodeOutputStore', () => {
const getNodeImageUrls = vi.fn()
return {
useNodeOutputStore: () => ({
getNodeImageUrls,
nodeOutputs: {},
nodePreviewImages: {}
})
}
})
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
import type { useImageCrop as UseImageCropFn } from './useImageCrop'
import { useImageCrop } from './useImageCrop'
function createMockNode(
overrides: Partial<Record<string, unknown>> = {}
): LGraphNode {
return {
getInputNode: vi.fn().mockReturnValue(null),
getInputLink: vi.fn(),
...overrides
} as unknown as LGraphNode
}
function createPointerEvent(
type: string,
clientX: number,
clientY: number
): PointerEvent {
const event = new PointerEvent(type, { clientX, clientY })
Object.defineProperty(event, 'target', {
value: {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn()
}
})
return event
}
function createOptions(modelValue?: Partial<Bounds>) {
const bounds: Bounds = {
x: 0,
y: 0,
width: 512,
height: 512,
...modelValue
}
return {
imageEl: ref<HTMLImageElement | null>(null),
containerEl: ref<HTMLDivElement | null>(null),
modelValue: ref(bounds)
}
}
// Single wrapper component used to trigger onMounted lifecycle
const Wrapper = defineComponent({
props: { run: { type: Function, required: true } },
setup(props) {
props.run()
return () => null
}
})
function mountComposable(
options: ReturnType<typeof createOptions>,
nodeId: NodeId = 1
) {
let result!: ReturnType<typeof UseImageCropFn>
mount(Wrapper, {
props: { run: () => (result = useImageCrop(nodeId, options)) }
})
return result
}
function setup(modelValue?: Partial<Bounds>, nodeId: NodeId = 1) {
const options = createOptions(modelValue)
const result = mountComposable(options, nodeId)
return { ...result, options }
}
function setupWithImage(
naturalWidth: number,
naturalHeight: number,
containerWidth: number,
containerHeight: number,
modelValue?: Partial<Bounds>
) {
const options = createOptions({
x: 0,
y: 0,
width: 100,
height: 100,
...modelValue
})
options.imageEl.value = {
naturalWidth,
naturalHeight
} as HTMLImageElement
options.containerEl.value = {
clientWidth: containerWidth,
clientHeight: containerHeight,
getBoundingClientRect: () => ({
width: containerWidth,
height: containerHeight,
x: 0,
y: 0,
top: 0,
left: 0,
right: containerWidth,
bottom: containerHeight,
toJSON: () => {}
})
} as unknown as HTMLDivElement
const result = mountComposable(options)
result.handleImageLoad()
return result
}
describe('useImageCrop', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(resolveNode).mockReturnValue(undefined)
})
describe('crop computed properties', () => {
it('reads crop dimensions from modelValue', () => {
const { cropX, cropY, cropWidth, cropHeight } = setup({
x: 10,
y: 20,
width: 200,
height: 300
})
expect(cropX.value).toBe(10)
expect(cropY.value).toBe(20)
expect(cropWidth.value).toBe(200)
expect(cropHeight.value).toBe(300)
})
it('writes crop dimensions back to modelValue', () => {
const { cropX, cropY, cropWidth, cropHeight, options } = setup()
cropX.value = 50
cropY.value = 60
cropWidth.value = 100
cropHeight.value = 150
expect(options.modelValue.value).toMatchObject({
x: 50,
y: 60,
width: 100,
height: 150
})
})
it('defaults cropWidth/cropHeight to 512 when modelValue is 0', () => {
const { cropWidth, cropHeight } = setup({ width: 0, height: 0 })
expect(cropWidth.value).toBe(512)
expect(cropHeight.value).toBe(512)
})
})
describe('cropBoxStyle', () => {
it('computes style from crop state and scale factor', () => {
const { cropBoxStyle } = setup({
x: 100,
y: 50,
width: 200,
height: 150
})
// With default scaleFactor=1 and offsets=0, border=2
expect(cropBoxStyle.value).toMatchObject({
left: `${100 * 1 - 2}px`,
top: `${50 * 1 - 2}px`,
width: `${200 * 1}px`,
height: `${150 * 1}px`
})
})
})
describe('selectedRatio', () => {
it('defaults to custom when no ratio is locked', () => {
const { selectedRatio } = setup()
expect(selectedRatio.value).toBe('custom')
})
it('sets lockedRatio when selecting a predefined ratio', () => {
const { selectedRatio, isLockEnabled } = setup()
selectedRatio.value = '16:9'
expect(isLockEnabled.value).toBe(true)
expect(selectedRatio.value).toBe('16:9')
})
it('clears lockedRatio when selecting custom', () => {
const { selectedRatio, isLockEnabled } = setup()
selectedRatio.value = '1:1'
expect(isLockEnabled.value).toBe(true)
selectedRatio.value = 'custom'
expect(isLockEnabled.value).toBe(false)
})
})
describe('isLockEnabled', () => {
it('derives locked ratio from current crop dimensions', () => {
const { isLockEnabled, selectedRatio } = setup({
width: 400,
height: 200
})
isLockEnabled.value = true
// Should compute ratio as 400/200 = 2, not match any preset
expect(selectedRatio.value).toBe('custom')
expect(isLockEnabled.value).toBe(true)
})
it('unlocks when set to false', () => {
const { isLockEnabled, selectedRatio } = setup()
isLockEnabled.value = true
isLockEnabled.value = false
expect(isLockEnabled.value).toBe(false)
expect(selectedRatio.value).toBe('custom')
})
})
describe('applyLockedRatio (via selectedRatio setter)', () => {
it('adjusts height to match 1:1 ratio when image is loaded', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 0,
y: 0,
width: 200,
height: 400
})
result.selectedRatio.value = '1:1'
expect(result.cropWidth.value).toBe(200)
expect(result.cropHeight.value).toBe(200)
})
it('clamps height and adjusts width at naturalHeight boundary', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 0,
y: 900,
width: 200,
height: 100
})
result.selectedRatio.value = '1:1'
// Only 100px remain (1000 - 900), so height=100, width=100
expect(result.cropHeight.value).toBeLessThanOrEqual(100)
expect(result.cropWidth.value).toBe(result.cropHeight.value)
})
})
describe('resizeHandles', () => {
it('returns all 8 handles when ratio is unlocked', () => {
const { resizeHandles } = setup()
expect(resizeHandles.value).toHaveLength(8)
})
it('returns only corner handles when ratio is locked', () => {
const { resizeHandles, isLockEnabled } = setup()
isLockEnabled.value = true
const directions = resizeHandles.value.map((h) => h.direction)
expect(directions).toEqual(['nw', 'ne', 'sw', 'se'])
expect(resizeHandles.value).toHaveLength(4)
})
})
describe('handleImageLoad', () => {
it('sets isLoading to false', () => {
const { isLoading, handleImageLoad } = setup()
isLoading.value = true
handleImageLoad()
expect(isLoading.value).toBe(false)
})
})
describe('handleImageError', () => {
it('sets isLoading to false and clears imageUrl', () => {
const { isLoading, imageUrl, handleImageError } = setup()
isLoading.value = true
imageUrl.value = 'http://example.com/img.png'
handleImageError()
expect(isLoading.value).toBe(false)
expect(imageUrl.value).toBeNull()
})
})
describe('handleDragStart/Move/End', () => {
it('does nothing when imageUrl is null', () => {
const { handleDragStart, cropX } = setup({ x: 100 })
handleDragStart(createPointerEvent('pointerdown', 50, 50))
expect(cropX.value).toBe(100)
})
it('ignores drag move when not dragging', () => {
const { handleDragMove, cropX } = setup({ x: 100 })
handleDragMove(createPointerEvent('pointermove', 200, 200))
expect(cropX.value).toBe(100)
})
it('ignores drag end when not dragging', () => {
const { handleDragEnd } = setup()
handleDragEnd(createPointerEvent('pointerup', 200, 200))
})
it('moves crop box by pointer delta', () => {
// 1000x1000 image in 500x500 container: effectiveScale = 0.5
// pointer delta of 50px -> natural delta of 50/0.5 = 100
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
result.handleDragMove(createPointerEvent('pointermove', 50, 30))
expect(result.cropX.value).toBe(200)
expect(result.cropY.value).toBe(160)
result.handleDragEnd(createPointerEvent('pointerup', 50, 30))
})
it('clamps drag to image boundaries', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 700,
y: 700,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleDragStart(createPointerEvent('pointerdown', 0, 0))
// delta = 500/0.5 = 1000, so x would be 1700 but max is 800
result.handleDragMove(createPointerEvent('pointermove', 500, 500))
expect(result.cropX.value).toBe(800)
expect(result.cropY.value).toBe(800)
result.handleDragEnd(createPointerEvent('pointerup', 500, 500))
})
})
describe('handleResizeStart/Move/End', () => {
it('does nothing when imageUrl is null', () => {
const { handleResizeStart, cropWidth } = setup({ width: 200 })
handleResizeStart(createPointerEvent('pointerdown', 50, 50), 'se')
expect(cropWidth.value).toBe(200)
})
it('ignores resize move when not resizing', () => {
const { handleResizeMove, cropWidth } = setup({ width: 200 })
handleResizeMove(createPointerEvent('pointermove', 300, 300))
expect(cropWidth.value).toBe(200)
})
it('ignores resize end when not resizing', () => {
const { handleResizeEnd } = setup()
handleResizeEnd(createPointerEvent('pointerup', 200, 200))
})
it('resizes from the right edge', () => {
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
result.handleResizeMove(createPointerEvent('pointermove', 25, 0))
expect(result.cropWidth.value).toBe(250)
expect(result.cropX.value).toBe(100)
result.handleResizeEnd(createPointerEvent('pointerup', 25, 0))
})
it('resizes from the bottom edge', () => {
// effectiveScale = 0.5, so pointer delta 40px -> natural 80px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(
createPointerEvent('pointerdown', 0, 0),
'bottom'
)
result.handleResizeMove(createPointerEvent('pointermove', 0, 40))
expect(result.cropHeight.value).toBe(280)
expect(result.cropY.value).toBe(100)
result.handleResizeEnd(createPointerEvent('pointerup', 0, 40))
})
it('resizes from left edge, moving x and shrinking width', () => {
// effectiveScale = 0.5, so pointer delta 25px -> natural 50px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 200,
y: 100,
width: 400,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'left')
result.handleResizeMove(createPointerEvent('pointermove', 50, 0))
// delta = 50/0.5 = 100 natural px; newX = 200+100 = 300, newW = 400-100 = 300
expect(result.cropX.value).toBe(300)
expect(result.cropWidth.value).toBe(300)
result.handleResizeEnd(createPointerEvent('pointerup', 50, 0))
})
it('enforces MIN_CROP_SIZE when resizing', () => {
// effectiveScale = 0.5, so pointer -200px -> natural -400px
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 50,
height: 50
})
result.imageUrl.value = 'http://example.com/img.png'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'right')
result.handleResizeMove(createPointerEvent('pointermove', -200, 0))
// MIN_CROP_SIZE = 16
expect(result.cropWidth.value).toBe(16)
result.handleResizeEnd(createPointerEvent('pointerup', -200, 0))
})
it('performs constrained resize with locked ratio from se corner', () => {
const result = setupWithImage(1000, 1000, 500, 500, {
x: 100,
y: 100,
width: 200,
height: 200
})
result.imageUrl.value = 'http://example.com/img.png'
result.selectedRatio.value = '1:1'
result.handleResizeStart(createPointerEvent('pointerdown', 0, 0), 'se')
result.handleResizeMove(createPointerEvent('pointermove', 100, 100))
// Both dimensions should grow equally for 1:1 ratio
expect(result.cropWidth.value).toBe(result.cropHeight.value)
expect(result.cropWidth.value).toBeGreaterThan(200)
result.handleResizeEnd(createPointerEvent('pointerup', 100, 100))
})
})
describe('getInputImageUrl (via imageUrl)', () => {
it('returns null when node is not found', () => {
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('returns URL from nodeOutputStore when node has output', () => {
const mockSourceNode = { isSubgraphNode: () => false }
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const store = useNodeOutputStore()
vi.mocked(store.getNodeImageUrls).mockReturnValue([
'http://example.com/output.png'
])
const { imageUrl } = setup()
expect(imageUrl.value).toBe('http://example.com/output.png')
})
it('returns null when source node has no output', () => {
const mockSourceNode = { isSubgraphNode: () => false }
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode)
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const store = useNodeOutputStore()
vi.mocked(store.getNodeImageUrls).mockReturnValue(undefined)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('returns null when node has no input node', () => {
const mockNode = createMockNode()
vi.mocked(resolveNode).mockReturnValue(mockNode)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('resolves subgraph node output link', () => {
const resolvedOutputNode = { isSubgraphNode: () => false }
const mockSourceNode = {
isSubgraphNode: () => true,
resolveSubgraphOutputLink: vi
.fn()
.mockReturnValue({ outputNode: resolvedOutputNode })
}
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const store = useNodeOutputStore()
vi.mocked(store.getNodeImageUrls).mockReturnValue([
'http://example.com/subgraph.png'
])
const { imageUrl } = setup()
expect(imageUrl.value).toBe('http://example.com/subgraph.png')
})
it('returns null when subgraph resolution fails (no link)', () => {
const mockSourceNode = {
isSubgraphNode: () => true,
resolveSubgraphOutputLink: vi.fn()
}
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
getInputLink: vi.fn().mockReturnValue(null)
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
it('returns null when subgraph resolves to no output node', () => {
const mockSourceNode = {
isSubgraphNode: () => true,
resolveSubgraphOutputLink: vi.fn().mockReturnValue({ outputNode: null })
}
const mockNode = createMockNode({
getInputNode: vi.fn().mockReturnValue(mockSourceNode),
getInputLink: vi.fn().mockReturnValue({ origin_slot: 0 })
})
vi.mocked(resolveNode).mockReturnValue(mockNode)
const { imageUrl } = setup()
expect(imageUrl.value).toBeNull()
})
})
describe('updateDisplayedDimensions (via handleImageLoad)', () => {
it('calculates scale for landscape image in smaller container', () => {
const result = setupWithImage(1000, 500, 500, 400)
// Landscape image: imageAspect=2 > containerAspect=1.25
// width-constrained: displayedWidth=500, scaleFactor=0.5
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.5}px`)
})
it('calculates scale for portrait image in wider container', () => {
const result = setupWithImage(500, 1000, 600, 400)
// Portrait: imageAspect=0.5 < containerAspect=1.5
// height-constrained: displayedWidth=200, scaleFactor=0.4
expect(result.cropBoxStyle.value.width).toBe(`${100 * 0.4}px`)
})
it('handles zero natural dimensions gracefully', () => {
const result = setupWithImage(0, 0, 500, 400, {
width: 512,
height: 512
})
// scaleFactor should default to 1
expect(result.cropBoxStyle.value.width).toBe(`${512}px`)
})
})
describe('initialize', () => {
it('calls resolveNode with the given nodeId', () => {
const mockNode = createMockNode()
vi.mocked(resolveNode).mockReturnValue(mockNode)
setup()
expect(resolveNode).toHaveBeenCalledWith(1)
})
it('sets node to null when resolveNode returns undefined', () => {
vi.mocked(resolveNode).mockReturnValue(undefined)
const { imageUrl } = setup()
expect(resolveNode).toHaveBeenCalledWith(1)
expect(imageUrl.value).toBeNull()
})
})
})

View File

@@ -0,0 +1,926 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock all sub-managers as classes (vi.fn().mockImplementation won't work as constructors)
vi.mock('./SceneManager', () => {
class MockSceneManager {
scene = { add: vi.fn(), remove: vi.fn(), traverse: vi.fn(), clear: vi.fn() }
gridHelper = { visible: true, position: { set: vi.fn() } }
backgroundTexture = null
backgroundMesh = null
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
toggleGrid = vi.fn()
setBackgroundColor = vi.fn()
setBackgroundImage = vi.fn().mockResolvedValue(undefined)
removeBackgroundImage = vi.fn()
setBackgroundRenderMode = vi.fn()
handleResize = vi.fn()
renderBackground = vi.fn()
captureScene = vi.fn().mockResolvedValue({
scene: 'data:scene',
mask: 'data:mask',
normal: 'data:normal'
})
updateBackgroundSize = vi.fn()
}
return { SceneManager: MockSceneManager }
})
vi.mock('./CameraManager', () => {
class MockCameraManager {
activeCamera = {
position: {
set: vi.fn(),
clone: vi.fn().mockReturnValue({ x: 0, y: 0, z: 0 }),
copy: vi.fn()
},
rotation: { clone: vi.fn(), copy: vi.fn() },
zoom: 1
}
perspectiveCamera = {
position: {
set: vi.fn(),
clone: vi.fn().mockReturnValue({ x: 0, y: 0, z: 0 }),
copy: vi.fn()
},
lookAt: vi.fn(),
updateProjectionMatrix: vi.fn(),
aspect: 1,
fov: 35
}
orthographicCamera = {
position: { set: vi.fn(), clone: vi.fn(), copy: vi.fn() }
}
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
setControls = vi.fn()
getCurrentCameraType = vi.fn().mockReturnValue('perspective')
toggleCamera = vi.fn()
setFOV = vi.fn()
setCameraState = vi.fn()
getCameraState = vi.fn().mockReturnValue({
position: { x: 10, y: 10, z: 10 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
})
handleResize = vi.fn()
updateAspectRatio = vi.fn()
setupForModel = vi.fn()
}
return { CameraManager: MockCameraManager }
})
vi.mock('./ControlsManager', () => {
class MockControlsManager {
controls = {
target: {
set: vi.fn(),
clone: vi.fn().mockReturnValue({ x: 0, y: 0, z: 0 }),
copy: vi.fn()
},
update: vi.fn(),
dispose: vi.fn(),
object: {}
}
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
update = vi.fn()
updateCamera = vi.fn()
}
return { ControlsManager: MockControlsManager }
})
vi.mock('./LightingManager', () => {
class MockLightingManager {
lights: never[] = []
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
setLightIntensity = vi.fn()
}
return { LightingManager: MockLightingManager }
})
vi.mock('./ViewHelperManager', () => {
class MockViewHelperManager {
viewHelper = {
render: vi.fn(),
update: vi.fn(),
dispose: vi.fn(),
animating: false,
visible: true,
center: null
}
viewHelperContainer = document.createElement('div')
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
createViewHelper = vi.fn()
update = vi.fn()
handleResize = vi.fn()
visibleViewHelper = vi.fn()
recreateViewHelper = vi.fn()
}
return { ViewHelperManager: MockViewHelperManager }
})
vi.mock('./LoaderManager', () => {
class MockLoaderManager {
init = vi.fn()
dispose = vi.fn()
loadModel = vi.fn().mockResolvedValue(undefined)
}
return { LoaderManager: MockLoaderManager }
})
vi.mock('./SceneModelManager', () => {
class MockSceneModelManager {
currentModel = null
originalModel = null
originalFileName: string | null = null
originalURL: string | null = null
originalRotation = null
currentUpDirection = 'original'
materialMode = 'original'
showSkeleton = false
originalMaterials = new WeakMap()
normalMaterial = {}
standardMaterial = {}
wireframeMaterial = {}
depthMaterial = {}
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
clearModel = vi.fn()
setupModel = vi.fn()
addModelToScene = vi.fn()
setOriginalModel = vi.fn()
setUpDirection = vi.fn()
setMaterialMode = vi.fn()
setupModelMaterials = vi.fn()
hasSkeleton = vi.fn().mockReturnValue(false)
setShowSkeleton = vi.fn()
containsSplatMesh = vi.fn().mockReturnValue(false)
}
return { SceneModelManager: MockSceneModelManager }
})
vi.mock('./RecordingManager', () => {
class MockRecordingManager {
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
startRecording = vi.fn().mockResolvedValue(undefined)
stopRecording = vi.fn()
getIsRecording = vi.fn().mockReturnValue(false)
getRecordingDuration = vi.fn().mockReturnValue(0)
getRecordingData = vi.fn().mockReturnValue(null)
exportRecording = vi.fn()
clearRecording = vi.fn()
}
return { RecordingManager: MockRecordingManager }
})
vi.mock('./AnimationManager', () => {
class MockAnimationManager {
animationClips: never[] = []
animationActions: never[] = []
isAnimationPlaying = false
currentAnimation = null
selectedAnimationIndex = 0
animationSpeed = 1.0
init = vi.fn()
dispose = vi.fn()
reset = vi.fn()
update = vi.fn()
setupModelAnimations = vi.fn()
setAnimationSpeed = vi.fn()
updateSelectedAnimation = vi.fn()
toggleAnimation = vi.fn()
getAnimationTime = vi.fn().mockReturnValue(0)
getAnimationDuration = vi.fn().mockReturnValue(0)
setAnimationTime = vi.fn()
}
return { AnimationManager: MockAnimationManager }
})
vi.mock('./ModelExporter', () => ({
ModelExporter: {
exportGLB: vi.fn().mockResolvedValue(undefined),
exportOBJ: vi.fn().mockResolvedValue(undefined),
exportSTL: vi.fn().mockResolvedValue(undefined)
}
}))
// Mock THREE.js — only the parts Load3d itself uses directly
vi.mock('three', () => {
const mockDomElement = document.createElement('canvas')
Object.defineProperty(mockDomElement, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(mockDomElement, 'clientHeight', {
value: 600,
configurable: true
})
class MockWebGLRenderer {
domElement = mockDomElement
autoClear = false
outputColorSpace = ''
toneMapping = 0
toneMappingExposure = 1
setSize = vi.fn()
setClearColor = vi.fn()
getClearColor = vi.fn()
getClearAlpha = vi.fn().mockReturnValue(1)
setViewport = vi.fn()
setScissor = vi.fn()
setScissorTest = vi.fn()
clear = vi.fn()
render = vi.fn()
dispose = vi.fn()
forceContextLoss = vi.fn()
}
class MockClock {
getDelta = vi.fn().mockReturnValue(0.016)
}
class MockVector3 {
x: number
y: number
z: number
constructor(x = 0, y = 0, z = 0) {
this.x = x
this.y = y
this.z = z
}
clone() {
return new MockVector3(this.x, this.y, this.z)
}
copy(v: MockVector3) {
this.x = v.x
this.y = v.y
this.z = v.z
return this
}
set(x: number, y: number, z: number) {
this.x = x
this.y = y
this.z = z
return this
}
}
class MockBox3 {
min = new MockVector3()
setFromObject() {
return this
}
getSize(v: MockVector3) {
v.x = 1
v.y = 1
v.z = 1
return v
}
getCenter(v: MockVector3) {
v.x = 0
v.y = 0
v.z = 0
return v
}
}
return {
WebGLRenderer: MockWebGLRenderer,
Clock: MockClock,
Vector3: MockVector3,
Box3: MockBox3,
SRGBColorSpace: 'srgb',
// Needed by sub-manager mocks at import time
Scene: vi.fn(),
PerspectiveCamera: vi.fn(),
OrthographicCamera: vi.fn(),
GridHelper: vi.fn(),
Color: vi.fn(),
BufferGeometry: vi.fn()
}
})
vi.mock('three/examples/jsm/controls/OrbitControls', () => ({
OrbitControls: vi.fn()
}))
vi.mock('three/examples/jsm/helpers/ViewHelper', () => ({
ViewHelper: vi.fn()
}))
// Indirect dependencies pulled in by mocked modules
vi.mock('@/i18n', () => ({ t: vi.fn((key: string) => key) }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn().mockReturnValue({ get: vi.fn() })
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn().mockReturnValue({ addAlert: vi.fn() })
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((p: string) => p),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: { getRandParam: vi.fn().mockReturnValue('&rand=1'), canvas: null }
}))
vi.mock('@/scripts/metadata/ply', () => ({
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
}))
vi.mock('@/base/common/downloadUtil', () => ({ downloadBlob: vi.fn() }))
import * as THREE from 'three'
import Load3d from './Load3d'
import type { CameraState } from './interfaces'
function createContainer(): HTMLDivElement {
const el = document.createElement('div')
Object.defineProperty(el, 'clientWidth', { value: 800 })
Object.defineProperty(el, 'clientHeight', { value: 600 })
document.body.appendChild(el)
return el
}
describe('Load3d', () => {
let load3d: Load3d
let container: HTMLDivElement
// Extra instances created in tests — tracked for cleanup
const extraInstances: Load3d[] = []
function createInstance(
options?: ConstructorParameters<typeof Load3d>[1]
): Load3d {
const instance = new Load3d(container, options)
vi.advanceTimersByTime(150)
extraInstances.push(instance)
return instance
}
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
container = createContainer()
load3d = new Load3d(container)
vi.advanceTimersByTime(150)
})
afterEach(() => {
extraInstances.forEach((i) => i.remove())
extraInstances.length = 0
vi.useRealTimers()
load3d.remove()
container.remove()
})
describe('constructor', () => {
it('appends the renderer canvas to the container', () => {
expect(container.querySelector('canvas')).not.toBeNull()
})
it('sets target dimensions from options', () => {
const sized = createInstance({ width: 1024, height: 768 })
expect(sized.targetWidth).toBe(1024)
expect(sized.targetHeight).toBe(768)
expect(sized.targetAspectRatio).toBeCloseTo(1024 / 768)
})
it('sets viewer mode from options', () => {
const viewer = createInstance({ isViewerMode: true })
expect(viewer.isViewerMode).toBe(true)
})
})
describe('isActive', () => {
it('returns false when no activity flags are set and initial render is done', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_NODE = false
load3d.STATUS_MOUSE_ON_SCENE = false
load3d.STATUS_MOUSE_ON_VIEWER = false
expect(load3d.isActive()).toBe(false)
})
it('returns true when mouse is on node', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_NODE = true
expect(load3d.isActive()).toBe(true)
})
it('returns true when mouse is on scene', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_SCENE = true
expect(load3d.isActive()).toBe(true)
})
it('returns true when mouse is on viewer', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.STATUS_MOUSE_ON_VIEWER = true
expect(load3d.isActive()).toBe(true)
})
it('returns true before initial render is done', () => {
load3d.INITIAL_RENDER_DONE = false
expect(load3d.isActive()).toBe(true)
})
it('returns true when animation is playing', () => {
load3d.INITIAL_RENDER_DONE = true
load3d.animationManager.isAnimationPlaying = true
expect(load3d.isActive()).toBe(true)
})
it('returns true when recording is active', () => {
load3d.INITIAL_RENDER_DONE = true
vi.mocked(load3d.recordingManager.getIsRecording).mockReturnValue(true)
expect(load3d.isActive()).toBe(true)
})
})
describe('getTargetSize / setTargetSize', () => {
it('returns current target dimensions', () => {
load3d.setTargetSize(640, 480)
expect(load3d.getTargetSize()).toEqual({ width: 640, height: 480 })
})
it('updates aspect ratio', () => {
load3d.setTargetSize(1920, 1080)
expect(load3d.targetAspectRatio).toBeCloseTo(1920 / 1080)
})
})
describe('addEventListener / removeEventListener', () => {
it('delegates to eventManager', () => {
const callback = vi.fn()
load3d.addEventListener('test', callback)
load3d.eventManager.emitEvent('test', 'payload')
expect(callback).toHaveBeenCalledWith('payload')
load3d.removeEventListener('test', callback)
load3d.eventManager.emitEvent('test', 'payload2')
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('scene delegation', () => {
it('toggleGrid delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.toggleGrid(false)
expect(load3d.sceneManager.toggleGrid).toHaveBeenCalledWith(false)
expect(renderSpy).toHaveBeenCalled()
})
it('setBackgroundColor delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setBackgroundColor('#ff0000')
expect(load3d.sceneManager.setBackgroundColor).toHaveBeenCalledWith(
'#ff0000'
)
expect(renderSpy).toHaveBeenCalled()
})
it('setBackgroundRenderMode delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setBackgroundRenderMode('panorama')
expect(load3d.sceneManager.setBackgroundRenderMode).toHaveBeenCalledWith(
'panorama'
)
expect(renderSpy).toHaveBeenCalled()
})
it('removeBackgroundImage delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.removeBackgroundImage()
expect(load3d.sceneManager.removeBackgroundImage).toHaveBeenCalled()
expect(renderSpy).toHaveBeenCalled()
})
it('captureScene delegates to sceneManager', () => {
load3d.captureScene(512, 512)
expect(load3d.sceneManager.captureScene).toHaveBeenCalledWith(512, 512)
})
})
describe('camera delegation', () => {
it('toggleCamera delegates and updates controls and viewHelper', () => {
load3d.toggleCamera('orthographic')
expect(load3d.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(load3d.controlsManager.updateCamera).toHaveBeenCalled()
expect(load3d.viewHelperManager.recreateViewHelper).toHaveBeenCalled()
})
it('getCurrentCameraType delegates', () => {
load3d.getCurrentCameraType()
expect(load3d.cameraManager.getCurrentCameraType).toHaveBeenCalled()
})
it('setFOV delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setFOV(60)
expect(load3d.cameraManager.setFOV).toHaveBeenCalledWith(60)
expect(renderSpy).toHaveBeenCalled()
})
it('setCameraState delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
const state: CameraState = {
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(0, 0, 0),
zoom: 1,
cameraType: 'perspective'
}
load3d.setCameraState(state)
expect(load3d.cameraManager.setCameraState).toHaveBeenCalledWith(state)
expect(renderSpy).toHaveBeenCalled()
})
it('getCameraState delegates', () => {
load3d.getCameraState()
expect(load3d.cameraManager.getCameraState).toHaveBeenCalled()
})
})
describe('model delegation', () => {
it('setMaterialMode delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setMaterialMode('wireframe')
expect(load3d.modelManager.setMaterialMode).toHaveBeenCalledWith(
'wireframe'
)
expect(renderSpy).toHaveBeenCalled()
})
it('setUpDirection delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setUpDirection('+z')
expect(load3d.modelManager.setUpDirection).toHaveBeenCalledWith('+z')
expect(renderSpy).toHaveBeenCalled()
})
it('getCurrentModel returns modelManager.currentModel', () => {
expect(load3d.getCurrentModel()).toBeNull()
})
it('isSplatModel delegates to modelManager', () => {
load3d.isSplatModel()
expect(load3d.modelManager.containsSplatMesh).toHaveBeenCalled()
})
})
describe('lighting delegation', () => {
it('setLightIntensity delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setLightIntensity(5)
expect(load3d.lightingManager.setLightIntensity).toHaveBeenCalledWith(5)
expect(renderSpy).toHaveBeenCalled()
})
})
describe('clearModel', () => {
it('disposes animations and clears model, then renders', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.clearModel()
expect(load3d.animationManager.dispose).toHaveBeenCalled()
expect(load3d.modelManager.clearModel).toHaveBeenCalled()
expect(renderSpy).toHaveBeenCalled()
})
})
describe('animation methods', () => {
it('hasAnimations returns false when empty', () => {
expect(load3d.hasAnimations()).toBe(false)
})
it('hasAnimations returns true when clips exist', () => {
load3d.animationManager.animationClips = [
{ name: 'clip' } as THREE.AnimationClip
]
expect(load3d.hasAnimations()).toBe(true)
})
it('setAnimationSpeed delegates', () => {
load3d.setAnimationSpeed(2.0)
expect(load3d.animationManager.setAnimationSpeed).toHaveBeenCalledWith(
2.0
)
})
it('updateSelectedAnimation delegates', () => {
load3d.updateSelectedAnimation(1)
expect(
load3d.animationManager.updateSelectedAnimation
).toHaveBeenCalledWith(1)
})
it('toggleAnimation delegates', () => {
load3d.toggleAnimation(true)
expect(load3d.animationManager.toggleAnimation).toHaveBeenCalledWith(true)
})
it('getAnimationTime delegates', () => {
load3d.getAnimationTime()
expect(load3d.animationManager.getAnimationTime).toHaveBeenCalled()
})
it('getAnimationDuration delegates', () => {
load3d.getAnimationDuration()
expect(load3d.animationManager.getAnimationDuration).toHaveBeenCalled()
})
it('setAnimationTime delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setAnimationTime(0.5)
expect(load3d.animationManager.setAnimationTime).toHaveBeenCalledWith(0.5)
expect(renderSpy).toHaveBeenCalled()
})
})
describe('recording methods', () => {
it('isRecording delegates', () => {
load3d.isRecording()
expect(load3d.recordingManager.getIsRecording).toHaveBeenCalled()
})
it('getRecordingDuration delegates', () => {
load3d.getRecordingDuration()
expect(load3d.recordingManager.getRecordingDuration).toHaveBeenCalled()
})
it('getRecordingData delegates', () => {
load3d.getRecordingData()
expect(load3d.recordingManager.getRecordingData).toHaveBeenCalled()
})
it('exportRecording delegates', () => {
load3d.exportRecording('test.mp4')
expect(load3d.recordingManager.exportRecording).toHaveBeenCalledWith(
'test.mp4'
)
})
it('clearRecording delegates', () => {
load3d.clearRecording()
expect(load3d.recordingManager.clearRecording).toHaveBeenCalled()
})
it('startRecording hides view helper and delegates', async () => {
await load3d.startRecording()
expect(load3d.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
false
)
expect(load3d.recordingManager.startRecording).toHaveBeenCalled()
})
it('stopRecording shows view helper and emits event', () => {
const emitSpy = vi.spyOn(load3d.eventManager, 'emitEvent')
load3d.stopRecording()
expect(load3d.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
true
)
expect(load3d.recordingManager.stopRecording).toHaveBeenCalled()
expect(emitSpy).toHaveBeenCalledWith('recordingStatusChange', false)
})
})
describe('skeleton methods', () => {
it('hasSkeleton delegates', () => {
load3d.hasSkeleton()
expect(load3d.modelManager.hasSkeleton).toHaveBeenCalled()
})
it('setShowSkeleton delegates and forces render', () => {
const renderSpy = vi.spyOn(load3d, 'forceRender')
load3d.setShowSkeleton(true)
expect(load3d.modelManager.setShowSkeleton).toHaveBeenCalledWith(true)
expect(renderSpy).toHaveBeenCalled()
})
it('getShowSkeleton reads modelManager state', () => {
load3d.modelManager.showSkeleton = true
expect(load3d.getShowSkeleton()).toBe(true)
})
})
describe('exportModel', () => {
it('throws when no model is loaded', async () => {
load3d.modelManager.currentModel = null
await expect(load3d.exportModel('glb')).rejects.toThrow(
'No model to export'
)
})
it('throws for unsupported format', async () => {
load3d.modelManager.currentModel = {
clone: vi.fn().mockReturnValue({})
} as unknown as THREE.Object3D
load3d.modelManager.originalFileName = 'test'
const promise = load3d.exportModel('xyz')
// exportModel uses setTimeout(resolve, 10) internally
vi.advanceTimersByTime(50)
await expect(promise).rejects.toThrow('Unsupported export format: xyz')
})
it('calls correct exporter and emits loading events for glb', async () => {
load3d.modelManager.currentModel = {
clone: vi.fn().mockReturnValue({})
} as unknown as THREE.Object3D
load3d.modelManager.originalFileName = 'test'
const { ModelExporter } = await import('./ModelExporter')
const emitSpy = vi.spyOn(load3d.eventManager, 'emitEvent')
const promise = load3d.exportModel('glb')
await vi.advanceTimersByTimeAsync(50)
await promise
expect(ModelExporter.exportGLB).toHaveBeenCalled()
expect(emitSpy).toHaveBeenCalledWith(
'exportLoadingStart',
'Exporting as GLB...'
)
expect(emitSpy).toHaveBeenCalledWith('exportLoadingEnd', null)
})
})
describe('loadModel', () => {
it('resets managers and delegates to loaderManager', async () => {
await load3d.loadModel('http://example.com/model.glb', 'model.glb')
expect(load3d.cameraManager.reset).toHaveBeenCalled()
expect(load3d.controlsManager.reset).toHaveBeenCalled()
expect(load3d.modelManager.clearModel).toHaveBeenCalled()
expect(load3d.animationManager.dispose).toHaveBeenCalled()
expect(load3d.loaderManager.loadModel).toHaveBeenCalledWith(
'http://example.com/model.glb',
'model.glb'
)
})
it('sets up animations when model has been loaded', async () => {
const mockModel = {} as unknown as THREE.Object3D
load3d.modelManager.currentModel = mockModel
load3d.modelManager.originalModel = {} as unknown as THREE.Object3D
await load3d.loadModel('http://example.com/model.glb', 'model.glb')
expect(load3d.animationManager.setupModelAnimations).toHaveBeenCalledWith(
mockModel,
load3d.modelManager.originalModel
)
})
it('serializes concurrent loadModel calls', async () => {
let resolveFirst!: () => void
const firstPromise = new Promise<void>((r) => {
resolveFirst = r
})
vi.mocked(load3d.loaderManager.loadModel)
.mockImplementationOnce(() => firstPromise)
.mockResolvedValueOnce(undefined)
const p1 = load3d.loadModel('url1')
const p2 = load3d.loadModel('url2')
resolveFirst()
await p1
await p2
expect(load3d.loaderManager.loadModel).toHaveBeenCalledTimes(2)
})
})
describe('captureThumbnail', () => {
it('throws when no model is loaded', async () => {
load3d.modelManager.currentModel = null
await expect(load3d.captureThumbnail()).rejects.toThrow(
'No model loaded for thumbnail capture'
)
})
})
describe('remove', () => {
it('disposes all managers and renderer', () => {
load3d.remove()
expect(load3d.sceneManager.dispose).toHaveBeenCalled()
expect(load3d.cameraManager.dispose).toHaveBeenCalled()
expect(load3d.controlsManager.dispose).toHaveBeenCalled()
expect(load3d.lightingManager.dispose).toHaveBeenCalled()
expect(load3d.viewHelperManager.dispose).toHaveBeenCalled()
expect(load3d.loaderManager.dispose).toHaveBeenCalled()
expect(load3d.modelManager.dispose).toHaveBeenCalled()
expect(load3d.recordingManager.dispose).toHaveBeenCalled()
expect(load3d.animationManager.dispose).toHaveBeenCalled()
})
})
describe('context menu behavior', () => {
it('calls onContextMenu callback on right-click without drag', () => {
const contextMenuFn = vi.fn()
const instance = createInstance({ onContextMenu: contextMenuFn })
const canvas = instance.renderer.domElement
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 2, clientX: 100, clientY: 100 })
)
canvas.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
cancelable: true,
bubbles: true
})
)
expect(contextMenuFn).toHaveBeenCalled()
})
it('suppresses context menu after right-drag beyond threshold', () => {
const contextMenuFn = vi.fn()
const instance = createInstance({ onContextMenu: contextMenuFn })
const canvas = instance.renderer.domElement
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 2, clientX: 100, clientY: 100 })
)
canvas.dispatchEvent(
new MouseEvent('mousemove', { buttons: 2, clientX: 150, clientY: 150 })
)
canvas.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: 150,
clientY: 150,
cancelable: true,
bubbles: true
})
)
expect(contextMenuFn).not.toHaveBeenCalled()
})
it('does not fire context menu in viewer mode', () => {
const contextMenuFn = vi.fn()
const instance = createInstance({
onContextMenu: contextMenuFn,
isViewerMode: true
})
const canvas = instance.renderer.domElement
canvas.dispatchEvent(
new MouseEvent('mousedown', { button: 2, clientX: 100, clientY: 100 })
)
canvas.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: 100,
clientY: 100,
cancelable: true,
bubbles: true
})
)
expect(contextMenuFn).not.toHaveBeenCalled()
})
})
describe('handleResize with getDimensions callback', () => {
it('uses getDimensions callback to update target size', () => {
const getDimensions = vi.fn().mockReturnValue({ width: 400, height: 300 })
const instance = createInstance({ getDimensions })
instance.handleResize()
expect(instance.targetWidth).toBe(400)
expect(instance.targetHeight).toBe(300)
})
it('keeps existing dimensions when getDimensions returns null', () => {
const getDimensions = vi.fn().mockReturnValue(null)
const instance = createInstance({
getDimensions,
width: 100,
height: 50
})
instance.handleResize()
expect(instance.targetWidth).toBe(100)
expect(instance.targetHeight).toBe(50)
})
})
})

View File

@@ -9,8 +9,14 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
export interface InputWidgetConfig {
height?: number
}
export type LinearInput = [NodeId, string, InputWidgetConfig?]
export interface LinearData {
inputs: [NodeId, string][]
inputs: LinearInput[]
outputs: NodeId[]
}

View File

@@ -67,6 +67,69 @@ describe('parseComfyWorkflow', () => {
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
describe('linearData.inputs schema', () => {
it('validates 2-tuple format (legacy)', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = {
linearData: { inputs: [[1, 'prompt']], outputs: [1] }
}
const result = await validateComfyWorkflow(workflow)
expect(result).not.toBeNull()
expect(result!.extra!.linearData!.inputs).toEqual([[1, 'prompt']])
})
it('validates 3-tuple format with config', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = {
linearData: { inputs: [[1, 'prompt', { height: 200 }]], outputs: [] }
}
const result = await validateComfyWorkflow(workflow)
expect(result).not.toBeNull()
expect(result!.extra!.linearData!.inputs![0]).toEqual([
1,
'prompt',
{ height: 200 }
])
})
it('validates 3-tuple format with empty config', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = {
linearData: { inputs: [[1, 'prompt', {}]], outputs: [] }
}
const result = await validateComfyWorkflow(workflow)
expect(result).not.toBeNull()
})
it('validates mixed 2-tuple and 3-tuple entries', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = {
linearData: {
inputs: [
[1, 'prompt'],
[2, 'seed', { height: 100 }]
],
outputs: []
}
}
const result = await validateComfyWorkflow(workflow)
expect(result).not.toBeNull()
expect(result!.extra!.linearData!.inputs).toEqual([
[1, 'prompt'],
[2, 'seed', { height: 100 }]
])
})
it('rejects invalid config shape', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = {
linearData: { inputs: [[1, 'prompt', 'invalid']], outputs: [] }
}
const result = await validateComfyWorkflow(workflow)
expect(result).toBeNull()
})
})
it('workflow.nodes.pos', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].pos = [1, 2, 3]

View File

@@ -285,7 +285,18 @@ const zExtra = z
linearMode: z.boolean().optional(),
linearData: z
.object({
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
inputs: z
.array(
z.union([
z.tuple([
zNodeId,
z.string(),
z.object({ height: z.number().optional() }).passthrough()
]),
z.tuple([zNodeId, z.string()])
])
)
.optional(),
outputs: z.array(zNodeId).optional()
})
.optional()

View File

@@ -2,7 +2,7 @@
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearInput } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
@@ -15,7 +15,7 @@ const { id, name } = defineProps<{
const appModeStore = useAppModeStore()
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
function matchesThis([nodeId, widgetName]: [NodeId, string]) {
function matchesThis([nodeId, widgetName]: LinearInput) {
return id == nodeId && name === widgetName
}
function togglePromotion() {

View File

@@ -103,6 +103,7 @@ async function rerun(e: Event) {
</Button>
<Button
v-if="isWorkflowActive && !selectedItem"
data-testid="linear-cancel-run"
variant="destructive"
@click="cancelActiveWorkflowJobs()"
>

View File

@@ -3,8 +3,6 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
const {
class: className,
overallOpacity = 1,
@@ -32,11 +30,13 @@ const executionStore = useExecutionStore()
"
>
<div
data-testid="linear-progress-overall"
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:class="cn(rounded && 'rounded-sm')"
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
/>
<div
data-testid="linear-progress-node"
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:class="cn(rounded && 'rounded-sm')"
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"

View File

@@ -327,6 +327,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
:key="`${item.id}-${item.state}`"
:ref="selectedRef(`slot:${item.id}`)"
v-bind="itemAttrs(`slot:${item.id}`)"
data-testid="linear-in-progress-item"
:class="itemClass"
@click="store.select(`slot:${item.id}`)"
>
@@ -359,6 +360,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
:key
:ref="selectedRef(`history:${asset.id}:${key}`)"
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
data-testid="linear-history-item"
:class="itemClass"
@click="store.select(`history:${asset.id}:${key}`)"
>

View File

@@ -58,6 +58,7 @@ function clearQueue(close: () => void) {
<div
v-if="queueCount > 1"
aria-hidden="true"
data-testid="linear-job-badge"
class="absolute top-0 right-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary-background text-xs text-text-primary"
v-text="queueCount"
/>

View File

@@ -15,6 +15,7 @@ const { output } = defineProps<{
<template>
<img
v-if="getMediaType(output) === 'images'"
data-testid="linear-image-output"
class="block size-10 rounded-sm bg-secondary-background object-cover"
loading="lazy"
width="40"
@@ -23,6 +24,7 @@ const { output } = defineProps<{
/>
<template v-else-if="getMediaType(output) === 'video'">
<video
data-testid="linear-video-output"
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
preload="metadata"
width="40"

View File

@@ -9,10 +9,19 @@ const { latentPreview } = defineProps<{
<div class="w-10">
<img
v-if="latentPreview"
data-testid="linear-latent-preview"
class="block size-10 rounded-sm object-cover"
:src="latentPreview"
/>
<div v-else class="skeleton-shimmer size-10 rounded-sm" />
<LinearProgressBar class="mt-1 h-1 w-10" rounded />
<div
v-else
data-testid="linear-skeleton"
class="skeleton-shimmer size-10 rounded-sm"
/>
<LinearProgressBar
data-testid="linear-item-progress-bar"
class="mt-1 h-1 w-10"
rounded
/>
</div>
</template>

View File

@@ -246,7 +246,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
if (!isJobForActiveWorkflow(jobId)) return
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
if (!sel || isFollowing.value) {
selectedId.value = slotId
isFollowing.value = true
return

View File

@@ -245,6 +245,19 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[1, 'prompt']])
})
it('preserves config through pruning', () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
id == 1 ? fromAny<LGraphNode, unknown>(node1) : undefined
)
store.loadSelections({
inputs: [[1, 'prompt', { height: 150 }]]
})
expect(store.selectedInputs).toEqual([[1, 'prompt', { height: 150 }]])
})
it('keeps inputs for existing nodes even if widget is missing', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
@@ -390,6 +403,46 @@ describe('appModeStore', () => {
})
})
describe('updateInputConfig', () => {
it('sets config on an existing input', () => {
store.selectedInputs.push([1, 'prompt'])
store.updateInputConfig(1 as NodeId, 'prompt', { height: 200 })
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
})
it('is a no-op when entry is not found', () => {
store.selectedInputs.push([1, 'prompt'])
store.updateInputConfig(99 as NodeId, 'prompt', { height: 200 })
expect(store.selectedInputs[0][2]).toBeUndefined()
})
it('matches nodeId with loose equality', () => {
store.selectedInputs.push(['1', 'prompt'])
store.updateInputConfig(1 as NodeId, 'prompt', { height: 200 })
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
})
it('triggers linearData sync watcher', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
store.updateInputConfig(42 as NodeId, 'prompt', { height: 300 })
await nextTick()
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [[42, 'prompt', { height: 300 }]],
outputs: []
})
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false

View File

@@ -5,7 +5,11 @@ import { useEventListener } from '@vueuse/core'
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import type {
InputWidgetConfig,
LinearData,
LinearInput
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -29,7 +33,7 @@ export const useAppModeStore = defineStore('appMode', () => {
const showVueNodeSwitchPopup = ref(false)
const selectedInputs = ref<[NodeId, string][]>([])
const selectedInputs = ref<LinearInput[]>([])
const selectedOutputs = ref<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.value.length)
const hasNodes = computed(() => {
@@ -160,6 +164,18 @@ export const useAppModeStore = defineStore('appMode', () => {
if (index !== -1) selectedInputs.value.splice(index, 1)
}
function updateInputConfig(
nodeId: NodeId,
widgetName: string,
config: InputWidgetConfig
) {
const entry = selectedInputs.value.find(
([id, name]) => nodeId == id && widgetName === name
)
if (!entry) return
entry[2] = { ...entry[2], ...config }
}
return {
enterBuilder,
exitBuilder,
@@ -171,6 +187,7 @@ export const useAppModeStore = defineStore('appMode', () => {
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs,
updateInputConfig,
showVueNodeSwitchPopup
}
})

View File

@@ -154,6 +154,7 @@ function dragDrop(e: DragEvent) {
@drop="dragDrop"
>
<LinearProgressBar
data-testid="linear-header-progress-bar"
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview

View File

@@ -63,7 +63,10 @@ const IS_NIGHTLY = process.env.IS_NIGHTLY === 'true'
let GIT_COMMIT = process.env.FRONTEND_COMMIT_HASH || ''
if (!GIT_COMMIT) {
try {
GIT_COMMIT = execSync('git rev-parse HEAD', { timeout: 5000 })
GIT_COMMIT = execSync('git rev-parse HEAD', {
timeout: 5000,
windowsHide: true
})
.toString()
.trim()
} catch {