Compare commits

...

10 Commits

Author SHA1 Message Date
bymyself
5868891a3d test: colocate WidgetImageCrop tests with component
Move the describe('WidgetImageCrop', ...) suite from
src/composables/useImageCrop.test.ts to
src/components/imagecrop/WidgetImageCrop.test.ts so that
component tests live alongside the component they exercise.
2026-04-18 18:49:43 -07:00
Dante
4c7729ee0b fix: remove hover dimming overlay on image nodes (#11296)
## Summary

Remove the black opacity/dimming overlay on image node hover and add
shadows to action buttons for visibility against light backgrounds.

## Changes

- **What**: Remove `opacity-50` dimming on hover in
`DisplayCarousel.vue`, remove `transition-opacity hover:opacity-80` from
grid thumbnails in `ImagePreview.vue`, add `shadow-md` to action buttons
in `ImagePreview.vue`. Applies to Save Image, Load Image, Preview Image,
and all nodes using these shared image components.

## Review Focus

Button shadows (`shadow-md`) should provide sufficient contrast against
light image backgrounds without needing the dimming overlay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11296-fix-remove-hover-dimming-overlay-on-image-nodes-3446d73d36508193bb5cc27d431014fd)
by [Unito](https://www.unito.io)
2026-04-18 22:40:11 +00:00
Dante
40083d593b test: cover Button, Textarea, Slider components (#11325)
Closes coverage gaps in \`src/components/ui/\` as part of the unit-test
backfill. Uses \`@testing-library/vue\` +
\`@testing-library/user-event\` for user-centric, behavioral assertions.

## Testing focus

Three Reka-UI primitives. The challenge is testing the contract — not
the library internals — given happy-dom's gaps and Reka's
\`useMounted()\`-based async initialization.

### \`Button\` (7 tests)

- Slot rendering + click event propagation.
- \`loading=true\`: three invariants hold **simultaneously** — slot
hidden, \`pi-spin\` spinner present, button is \`toBeDisabled()\`.
- \`disabled=true\` alone: button disabled, no spinner.
- \`as="a"\`: polymorphic root tag (Reka \`Primitive\`'s \`as\` prop
switches the rendered element).
- Variant class pass-through: **one** deliberate style assertion because
the variant-system wiring is part of the component's public contract. No
other styling/class checks (AGENTS.md bans class-based tests).

### \`Textarea\` (6 tests)

- \`v-model\` two-way binding: \`user.type()\` updates the bound ref;
initial value populates the textarea.
- \`disabled\` asserted **behaviorally** — typing is blocked when
disabled, not just the attribute presence.
- Pass-through: \`placeholder\`, \`rows\`, \`class\`.

### \`Slider\` (8 tests)

- Thumb count matches \`modelValue.length\` (range support).
- ARIA: \`aria-valuemin\` / \`aria-valuemax\` / \`aria-valuenow\`.
**Caveat:** Reka's \`SliderRoot\` uses \`useMounted()\`, so
\`aria-valuenow\` is absent on the first render tick. The tests use a
two-tick \`flush()\` helper (\`await nextTick()\` twice) to wait it out
— no mocking of Reka required.
- Keyboard drag: \`user.keyboard('{ArrowRight}')\` / \`'{ArrowLeft}'\`
moves the value; with \`step: 10\` starting from 50, ArrowRight produces
exactly \`[60]\`.
- \`disabled\` → no emit on keyboard events.

### Reka integration limit

Pointer-driven \`slide-start\` / \`slide-end\` gestures in happy-dom
would require faking \`getBoundingClientRect\` and \`setPointerCapture\`
— that crosses into mocking Reka internals. Keyboard-drag paths are
covered instead (the user-facing contract); the \`pressed\` CSS state is
exercised implicitly by surviving a full mount + update cycle.

## Principles applied

- No mocks of Vue, Reka, or \`@vueuse/core\`.
- Queries via \`getByRole\` / \`getByLabelText\`; **no** class-name or
Tailwind-token queries (per AGENTS.md).
- All 21 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-18 22:36:16 +00:00
Dante
7089a7d1a0 fix: show asset display names in bulk delete confirmation (#11321)
## Summary
Bulk-delete confirmation on Comfy Cloud listed raw SHA-256 filenames,
making the modal impossible to use to verify what would be deleted.

## Changes
- **What**: `useMediaAssetActions.deleteAssets` now maps each asset
through `getAssetDisplayName`, so the confirmation's `itemList` matches
the user-assigned names shown in the left media panel
(`MediaAssetCard`).
- **Tests**: Added two regression tests covering `user_metadata.name` /
`display_name` resolution and the `asset.name` fallback.

## Review Focus
- Parity with `MediaAssetCard` display: we reuse the same
`getAssetDisplayName` helper; extension stripping (via
`getFilenameDetails`) is not applied in the modal since file extensions
are useful context when confirming deletions.

Reported in Slack:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776383570015289

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11321-fix-show-asset-display-names-in-bulk-delete-confirmation-3456d73d36508108a3d5f2290ca39e18)
by [Unito](https://www.unito.io)
2026-04-18 22:35:39 +00:00
Christian Byrne
3b4811b00d feat: deploy E2E coverage HTML report to GitHub Pages (#11291)
## Summary

Browsable E2E coverage report deployed to GitHub Pages on every main
merge, replacing the current workflow of downloading LCOV artifacts and
using an external viewer.

## Changes

- **What**: After merging shard LCOVs, run `genhtml` to produce an HTML
report with per-file line coverage. On `main`, deploy to GitHub Pages
via `actions/deploy-pages`. For PR runs, the HTML report is still
available as the `e2e-coverage-html` artifact.
- **Dependencies**: None new — `genhtml` is part of the `lcov` package
already installed in the workflow.

## Review Focus

- **GitHub Pages must be enabled**: Settings → Pages → Source → "GitHub
Actions". Without this the deploy job will fail silently.
- The deploy job only runs for `main` branch (`if:
github.event.workflow_run.head_branch == 'main'`) so PR coverage doesn't
clobber the deployed report.
- Added `pages: write` and `id-token: write` permissions to the workflow
for the Pages deployment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11291-feat-deploy-E2E-coverage-HTML-report-to-GitHub-Pages-3446d73d36508136ba6fd806690c9cfc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-18 15:40:59 -07:00
jaeone94
b756545f59 refactor: clean up ChangeTracker logging, guards, and redundant widget wrapper (#11328)
## Summary

Follow-ups to PR #10816. Bundles four review items left open after that
PR merged — three inside `ChangeTracker` itself and one in the widget
composable that wraps it.

### What changed

- **Removed all `loglevel` logging from `src/scripts/changeTracker.ts`**
— the logger was set to `info`, so every `logger.debug` call was dead
code at runtime. `logger.warn` calls were replaced with direct
reporting. The only-downstream dead code (`graphDiff` helper) and its
sole dependency (`jsondiffpatch`) are also removed.
- **Named the `captureCanvasState()` guard conditions** —
`isUndoRedoing` and `isInsideChangeTransaction` now carry the intent
that the inline `_restoringState` / `changeCount > 0` expressions used
to obscure.
- **Surfaced lifecycle violations through a single reporting helper** —
`reportInactiveTrackerCall()` logs `console.warn` once per method per
session and, on Desktop, emits a `Sentry.addBreadcrumb` with the
offending workflow path. `deactivate()` and `captureCanvasState()` share
this path so the same invariant is reported consistently.
- **Inlined `captureWorkflowState` wrapper in `useWidgetSelectActions`**
— the private helper forwarded to `changeTracker.captureCanvasState()`
with no added logic. Both call sites now invoke the change tracker
directly.

### Issues fixed

- Fixes #11249
- Fixes #11259
- Fixes #11258
- Fixes #11248

### Test plan

- [x] `pnpm test:unit src/scripts/changeTracker.test.ts` — 16 tests pass
- [x] `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.test.ts`
— 6 tests pass
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`
2026-04-18 22:28:05 +00:00
Alexander Brown
da91bdc957 fix: persist middle-click reroute node setting across reloads (#11362)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Remove hardcoded `LiteGraph.middle_click_slot_add_default_node = true`
from `slotDefaults` extension `init()` that unconditionally overrode the
user's persisted preference on every page load
- Add E2E regression test verifying both the setting store value and the
LiteGraph runtime flag persist through page reload

## Root Cause

The `Comfy.SlotDefaults` extension's `init()` method (in
`slotDefaults.ts`) contained a hardcoded
`LiteGraph.middle_click_slot_add_default_node = true` from the original
JS→TS conversion (July 2024). When `Comfy.Node.MiddleClickRerouteNode`
was later made configurable in v1.3.42, this line was never removed.
Since extension `init()` runs **after** `useLitegraphSettings()` syncs
the stored value, the hardcoded assignment overwrote the user's
preference on every reload.

## Changes

| File | Change |
|------|--------|
| `src/extensions/core/slotDefaults.ts` | Remove line 21
(`LiteGraph.middle_click_slot_add_default_node = true`) |
| `browser_tests/tests/dialogs/settingsDialog.spec.ts` | Add reload
persistence test asserting both store value and LiteGraph global |

The setting default (`true`) is already properly managed by
`coreSettings.ts` and reactively synced via `useLitegraphSettings.ts`,
so removing the hardcoded line preserves existing default behavior while
allowing user overrides to persist.

## Screenshots

![Setting shown as enabled (default
state)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970358-dcd6bd51-00c8-4ed4-86ce-0f1a89576f52.png)

![Setting toggled off by
user](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970719-fb1f587f-964d-4e6c-954e-3145812badaf.png)

![Setting correctly persists as off after page reload (with fix
applied)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528971113-36b577cb-5fd1-445d-8c8f-3ea8f6f46326.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11362-fix-persist-middle-click-reroute-node-setting-across-reloads-3466d73d365081ef8692dbd0619c8594)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 21:29:44 +00:00
Christian Byrne
cf3006f82c fix: reduce noise in coverage Slack notifications (#11283)
## Summary

Suppress low-signal coverage Slack notifications that show +0.0% or
-0.0% deltas.

## Changes

- **What**: Add `MIN_DELTA` threshold (0.05%) so only meaningful
improvements trigger notifications. Only display rows for metrics that
actually improved (no more E2E row showing -0.0% alongside a real unit
improvement). Fix `formatDelta` to clamp near-zero values to `+0.0%`
instead of showing `-0.0%`.
- 4 of the first 6 notifications posted were noise (+0.0% deltas from
instrumentation jitter). With this change, only 2 of 6 would have been
posted — both showing real improvements.

## Review Focus

The `MIN_DELTA` value of 0.05 means any delta that rounds to ±0.0% at 1
decimal place is suppressed. This matches the display precision so users
never see +0.0% notifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11283-fix-reduce-noise-in-coverage-Slack-notifications-3436d73d3650819ab3bcfebdb748ac8b)
by [Unito](https://www.unito.io)
2026-04-18 13:28:32 -07:00
pythongosssss
be2d757c47 test: add regression test for getCanvasCenter null guard (#8399) (#11271)
## Summary

Add a regression test for #8399 (null check in `getCanvasCenter` to
prevent crash on asset insert). The fix in
`src/services/litegraphService.ts` added optional chaining around
`app.canvas?.ds?.visible_area` with a `[0, 0]` fallback so inserting an
asset before the canvas finishes initializing no longer crashes. There
was no existing unit test for `litegraphService`, so this regression
could silently return.

## Changes

- **What**: New unit test file `src/services/litegraphService.test.ts`
covering `useLitegraphService().getCanvasCenter`.
- Mocks `@/scripts/app` so `app.canvas` can be swapped per test via
`Reflect.set`.
- Null-canvas case (regression for #8399): returns `[0, 0]` instead of
throwing.
- Missing `ds.visible_area` case: also returns `[0, 0]`.
- Initialised case: returns the centre of the visible area.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11271-test-add-regression-test-for-getCanvasCenter-null-guard-8399-3436d73d3650815c9925c8fdf9ec4bd3)
by [Unito](https://www.unito.io)
2026-04-18 16:32:03 +00:00
Terry Jia
54f3127658 test: regenerate screenshot expectations (#11360)
## Summary
regenerate screenshot expectations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11360-test-regenerate-screenshot-expectations-3466d73d365081878addd53a266a31b7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-18 09:10:02 -04:00
21 changed files with 807 additions and 327 deletions

View File

@@ -98,3 +98,50 @@ jobs:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
expect(switched).toBe(true)
})
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
try {
await comfyPage.settings.setSetting(settingId, !initialValue)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await expect
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
.toBe(!initialValue)
await expect
.poll(() =>
comfyPage.page.evaluate(
() => window.LiteGraph!.middle_click_slot_add_default_node
)
)
.toBe(!initialValue)
} finally {
await comfyPage.settings.setSetting(settingId, initialValue)
}
})
test('Dropdown setting can be changed and persists', async ({
comfyPage
}) => {

View File

@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
)
test(
'Empty state matches screenshot baseline',
'Empty state matches the screenshot baseline',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -102,7 +102,6 @@
"fuse.js": "^7.0.0",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",

20
pnpm-lock.yaml generated
View File

@@ -267,9 +267,6 @@ catalogs:
jsonata:
specifier: ^2.1.0
version: 2.1.0
jsondiffpatch:
specifier: ^0.7.3
version: 0.7.3
knip:
specifier: ^6.3.1
version: 6.3.1
@@ -557,9 +554,6 @@ importers:
jsonata:
specifier: 'catalog:'
version: 2.1.0
jsondiffpatch:
specifier: 'catalog:'
version: 0.7.3
loglevel:
specifier: ^1.9.2
version: 1.9.2
@@ -1780,9 +1774,6 @@ packages:
'@cyberalien/svg-utils@1.1.1':
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
'@dmsnell/diff-match-patch@1.1.0':
resolution: {integrity: sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==}
'@dual-bundle/import-meta-resolve@4.2.1':
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
@@ -7269,11 +7260,6 @@ packages:
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsondiffpatch@0.7.3:
resolution: {integrity: sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -11239,8 +11225,6 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@dmsnell/diff-match-patch@1.1.0': {}
'@dual-bundle/import-meta-resolve@4.2.1': {}
'@emmetio/abbreviation@2.3.3':
@@ -17140,10 +17124,6 @@ snapshots:
jsonc-parser@3.3.1: {}
jsondiffpatch@0.7.3:
dependencies:
'@dmsnell/diff-match-patch': 1.1.0
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1

View File

@@ -90,7 +90,6 @@ catalog:
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.3.1
lenis: ^1.3.21
lint-staged: ^16.2.7

View File

@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
const TARGET = 80
const MILESTONE_STEP = 5
const MIN_DELTA = 0.05
const BAR_WIDTH = 20
interface CoverageData {
@@ -71,8 +72,9 @@ function formatPct(value: number): string {
}
function formatDelta(delta: number): string {
const sign = delta >= 0 ? '+' : ''
return sign + delta.toFixed(1) + '%'
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
const sign = rounded >= 0 ? '+' : ''
return sign + rounded.toFixed(1) + '%'
}
function crossedMilestone(prev: number, curr: number): number | null {
@@ -150,15 +152,18 @@ function main() {
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
const unitImproved =
unitCurrent !== null &&
unitBaseline !== null &&
unitCurrent.percentage > unitBaseline.percentage
const unitDelta =
unitCurrent !== null && unitBaseline !== null
? unitCurrent.percentage - unitBaseline.percentage
: 0
const e2eImproved =
e2eCurrent !== null &&
e2eBaseline !== null &&
e2eCurrent.percentage > e2eBaseline.percentage
const e2eDelta =
e2eCurrent !== null && e2eBaseline !== null
? e2eCurrent.percentage - e2eBaseline.percentage
: 0
const unitImproved = unitDelta >= MIN_DELTA
const e2eImproved = e2eDelta >= MIN_DELTA
if (!unitImproved && !e2eImproved) {
process.exit(0)
@@ -172,12 +177,12 @@ function main() {
)
summaryLines.push('')
if (unitCurrent && unitBaseline) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
if (unitImproved) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
}
if (e2eCurrent && e2eBaseline) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
if (e2eImproved) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
}
summaryLines.push('')

View File

@@ -0,0 +1,268 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import WidgetImageCrop from './WidgetImageCrop.vue'
const resizeObserverCallbacks: Array<() => void> = []
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...(actual as Record<string, unknown>),
useResizeObserver: (_target: unknown, cb: () => void) => {
resizeObserverCallbacks.push(cb)
return { stop: vi.fn() }
}
}
})
const mockResolveNode = vi.hoisted(() =>
vi.fn<(id: NodeId) => LGraphNode | null>()
)
vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: (id: NodeId) => mockResolveNode(id)
}))
const mockGetNodeImageUrls = vi.hoisted(() =>
vi.fn<(node: LGraphNode) => string[] | null | undefined>()
)
type MockOutputStore = {
nodeOutputs: Record<string, unknown>
nodePreviewImages: Record<string, unknown>
getNodeImageUrls: typeof mockGetNodeImageUrls
}
const useNodeOutputStoreMock = vi.hoisted(() => vi.fn<() => MockOutputStore>())
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => useNodeOutputStoreMock()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: { id: 'test-graph' }
}
}
})
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({
getNodeWidgets: vi.fn(() => [])
})
}))
async function flushTicks() {
await Promise.resolve()
await nextTick()
}
describe('WidgetImageCrop', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
imageCrop: {
loading: 'Loading...',
noInputImage: 'No input image connected',
cropPreviewAlt: 'Crop preview',
ratio: 'Ratio',
lockRatio: 'Lock aspect ratio',
unlockRatio: 'Unlock aspect ratio',
custom: 'Custom'
}
}
}
})
beforeEach(() => {
resizeObserverCallbacks.length = 0
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
nodePreviewImages: reactive<Record<string, unknown>>({}),
getNodeImageUrls: mockGetNodeImageUrls
}
useNodeOutputStoreMock.mockReturnValue(outputStore)
const source = createMockLGraphNode({ id: 99, isSubgraphNode: () => false })
const crop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => source),
getInputLink: vi.fn(),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(crop)
mockGetNodeImageUrls.mockImplementation((n) =>
n === source ? ['https://example.com/a.png'] : null
)
setActivePinia(createTestingPinia({ stubActions: true }))
})
it('renders empty state copy when no image URL is available', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('No input image connected')).toBeTruthy()
unmount()
attach.remove()
})
it('shows crop overlay after the preview image loads', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
attach.remove()
})
it('toggles aspect ratio lock from the toolbar button', async () => {
const user = userEvent.setup()
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))
await flushTicks()
expect(
screen.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeTruthy()
unmount()
attach.remove()
})
it('renders ratio controls when the widget is enabled', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('Ratio')).toBeTruthy()
unmount()
attach.remove()
})
})

View File

@@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Button from './Button.vue'
describe('Button', () => {
it('renders slot content inside a button by default', () => {
render(Button, {
slots: { default: 'Click me' }
})
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('fires click events when enabled', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(Button, {
slots: { default: 'Click me' },
attrs: { onClick }
})
await user.click(screen.getByRole('button', { name: 'Click me' }))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('hides slot content, shows a spinner, and disables the button while loading', () => {
const { container } = render(Button, {
props: { loading: true },
slots: { default: 'Submit' }
})
expect(screen.queryByText('Submit')).not.toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue spinner icon has no accessible role
expect(container.querySelector('.pi-spin')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeDisabled()
})
it('does not fire click when loading', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(Button, {
props: { loading: true },
attrs: { onClick }
})
await user.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('disables the button when disabled prop is true', () => {
render(Button, {
props: { disabled: true },
slots: { default: 'Nope' }
})
expect(screen.getByRole('button', { name: 'Nope' })).toBeDisabled()
})
it('renders as an anchor when as="a"', () => {
const { container } = render(Button, {
props: { as: 'a' },
slots: { default: 'Link' }
})
// eslint-disable-next-line testing-library/no-node-access -- root element tag is the contract under test
const root = container.firstElementChild
expect(root?.tagName).toBe('A')
})
it('applies variant classes through buttonVariants', () => {
render(Button, {
props: { variant: 'primary' },
slots: { default: 'Primary' }
})
expect(screen.getByRole('button', { name: 'Primary' })).toHaveClass(
'bg-primary-background'
)
})
})

View File

@@ -0,0 +1,141 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import Slider from './Slider.vue'
async function flush() {
await nextTick()
await nextTick()
}
describe('Slider', () => {
it('renders a single thumb with role="slider" for a single-value model', async () => {
render(Slider, { props: { modelValue: [50] } })
await flush()
const thumbs = screen.getAllByRole('slider')
expect(thumbs).toHaveLength(1)
})
it('renders one thumb per value for a range model', async () => {
render(Slider, { props: { modelValue: [20, 50] } })
await flush()
const thumbs = screen.getAllByRole('slider')
expect(thumbs).toHaveLength(2)
})
it('exposes min/max/step via ARIA on the thumb', async () => {
render(Slider, {
props: { modelValue: [10], min: 0, max: 200, step: 5 }
})
await flush()
const thumb = screen.getByRole('slider')
expect(thumb).toHaveAttribute('aria-valuemin', '0')
expect(thumb).toHaveAttribute('aria-valuemax', '200')
expect(thumb).toHaveAttribute('aria-valuenow', '10')
})
it('emits update:modelValue with an increased value on ArrowRight', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalled()
const latest = onUpdate.mock.calls.at(-1)?.[0]
expect(latest?.[0]).toBeGreaterThan(50)
})
it('emits update:modelValue with a decreased value on ArrowLeft', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowLeft}')
expect(onUpdate).toHaveBeenCalled()
const latest = onUpdate.mock.calls.at(-1)?.[0]
expect(latest?.[0]).toBeLessThan(50)
})
it('respects step size when emitting updates', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 10,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).toHaveBeenCalledWith([60])
})
it('marks the root as disabled when disabled prop is set', async () => {
const { container } = render(Slider, {
props: { modelValue: [30], disabled: true }
})
await flush()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Reka exposes disabled state as a data attribute on the root
const root = container.querySelector('[data-slot="slider"]')
expect(root).toHaveAttribute('data-disabled')
})
it('does not emit updates via keyboard when disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Slider, {
props: {
modelValue: [50],
min: 0,
max: 100,
step: 1,
disabled: true,
'onUpdate:modelValue': onUpdate
}
})
await flush()
screen.getByRole('slider').focus()
await user.keyboard('{ArrowRight}')
expect(onUpdate).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Textarea from './Textarea.vue'
describe('Textarea', () => {
it('renders a textarea element', () => {
render(Textarea)
expect(screen.getByRole('textbox')).toBeInstanceOf(HTMLTextAreaElement)
})
it('populates the textarea with the initial v-model value', () => {
render(Textarea, { props: { modelValue: 'initial text' } })
expect(screen.getByRole('textbox')).toHaveValue('initial text')
})
it('emits update:modelValue as the user types', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn<(value: string | number | undefined) => void>()
render(Textarea, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
}
})
await user.type(screen.getByRole('textbox'), 'hi')
expect(onUpdate).toHaveBeenCalled()
expect(onUpdate.mock.calls.at(-1)?.[0]).toBe('hi')
})
it('forwards placeholder and rows attrs to the native textarea', () => {
render(Textarea, {
attrs: { placeholder: 'Write something', rows: 6 }
})
const textarea = screen.getByPlaceholderText('Write something')
expect(textarea).toHaveAttribute('rows', '6')
})
it('does not accept typed input when disabled', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
render(Textarea, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
attrs: { disabled: true }
})
const textarea = screen.getByRole('textbox')
expect(textarea).toBeDisabled()
await user.type(textarea, 'blocked')
expect(onUpdate).not.toHaveBeenCalled()
expect(textarea).toHaveValue('')
})
it('forwards custom class alongside internal classes', () => {
render(Textarea, { props: { class: 'custom-extra-class' } })
expect(screen.getByRole('textbox')).toHaveClass('custom-extra-class')
})
})

View File

@@ -1,18 +1,12 @@
/* eslint-disable vue/one-component-per-file */
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WidgetImageCrop from '@/components/imagecrop/WidgetImageCrop.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
createMockLGraphNode,
createMockSubgraphNode
@@ -164,10 +158,10 @@ type CropVm = Record<string, unknown> & {
function setupImageLayout(vm: CropVm, nw: number, nh: number) {
/* Harness root + image are not RTL queries — layout is driven by composable state */
/* eslint-disable testing-library/no-node-access */
const container = vm.$el as HTMLDivElement
const img = container.querySelector('img')
/* eslint-enable testing-library/no-node-access */
mountContainerLayout(container, 400, 300)
if (img) {
Object.defineProperty(img, 'naturalWidth', {
@@ -374,10 +368,10 @@ describe('useImageCrop', () => {
it('uses scale factor 1 when natural dimensions are zero', async () => {
const vm = await mountHarness()
/* eslint-disable testing-library/no-node-access */
const container = vm.$el as HTMLDivElement
const img = container.querySelector('img')
/* eslint-enable testing-library/no-node-access */
if (!img) throw new Error('expected preview img')
Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 0 })
Object.defineProperty(img, 'naturalHeight', {
@@ -580,199 +574,3 @@ describe('useImageCrop', () => {
expect(vm.cropHeight as number).toBeGreaterThan(h0)
})
})
describe('WidgetImageCrop', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
imageCrop: {
loading: 'Loading...',
noInputImage: 'No input image connected',
cropPreviewAlt: 'Crop preview',
ratio: 'Ratio',
lockRatio: 'Lock aspect ratio',
unlockRatio: 'Unlock aspect ratio',
custom: 'Custom'
}
}
}
})
beforeEach(() => {
resizeObserverCallbacks.length = 0
vi.clearAllMocks()
const outputStore: MockOutputStore = {
nodeOutputs: reactive<Record<string, unknown>>({}),
nodePreviewImages: reactive<Record<string, unknown>>({}),
getNodeImageUrls: mockGetNodeImageUrls
}
useNodeOutputStoreMock.mockReturnValue(outputStore)
const source = createMockLGraphNode({ id: 99, isSubgraphNode: () => false })
const crop = createMockLGraphNode({
id: 2,
getInputNode: vi.fn(() => source),
getInputLink: vi.fn(),
isSubgraphNode: () => false
})
mockResolveNode.mockReturnValue(crop)
mockGetNodeImageUrls.mockImplementation((n) =>
n === source ? ['https://example.com/a.png'] : null
)
setActivePinia(createTestingPinia({ stubActions: true }))
})
it('renders empty state copy when no image URL is available', async () => {
mockGetNodeImageUrls.mockReturnValue(null)
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('No input image connected')).toBeTruthy()
unmount()
attach.remove()
})
it('shows crop overlay after the preview image loads', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
unmount()
attach.remove()
})
it('toggles aspect ratio lock from the toolbar button', async () => {
const user = userEvent.setup()
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
attach.style.width = '420px'
attach.style.height = '320px'
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
const img = screen.getByAltText('Crop preview')
Object.defineProperty(img, 'naturalWidth', {
configurable: true,
value: 400
})
Object.defineProperty(img, 'naturalHeight', {
configurable: true,
value: 400
})
img.dispatchEvent(new Event('load'))
await flushTicks()
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))
await flushTicks()
expect(
screen.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeTruthy()
unmount()
attach.remove()
})
it('renders ratio controls when the widget is enabled', async () => {
const widget = fromPartial<SimplifiedWidget>({
type: 'imagecrop',
options: {}
})
const attach = document.createElement('div')
document.body.appendChild(attach)
const { unmount } = render(WidgetImageCrop, {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
plugins: [i18n],
stubs: {
WidgetBoundingBox: {
name: 'WidgetBoundingBox',
template: '<div data-testid="bbox-stub" />'
}
}
}
})
await flushTicks()
expect(screen.getByText('Ratio')).toBeTruthy()
unmount()
attach.remove()
})
})

View File

@@ -18,7 +18,6 @@ app.registerExtension({
suggestionsNumber: null,
init(this: SlotDefaultsExtension) {
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
id: 'Comfy.NodeSuggestions.number',
category: ['Comfy', 'Node Search Box', 'NodeSuggestions'],

View File

@@ -484,4 +484,56 @@ describe('useMediaAssetActions', () => {
)
})
})
describe('deleteAssets - confirmation dialog item names', () => {
beforeEach(() => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockShowDialog.mockReset()
})
it('should show user_metadata display names instead of hash filenames', () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({
id: 'asset-1',
name: 'c885097ab185ced82f017bcbc98948918499f7480315fd5b928b5bb8d4951efc.png',
user_metadata: { name: 'My Sunset Render' }
}),
createMockAsset({
id: 'asset-2',
name: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2.png',
display_name: 'Portrait Variation'
})
]
void actions.deleteAssets(assets)
expect(mockShowDialog).toHaveBeenCalledTimes(1)
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
itemList: string[]
}
expect(dialogProps.itemList).toEqual([
'My Sunset Render',
'Portrait Variation'
])
})
it('should fall back to asset.name when no display name is available', () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
id: 'asset-3',
name: 'fallback-image.png'
})
void actions.deleteAssets(asset)
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
itemList: string[]
}
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
})
})
})

View File

@@ -595,7 +595,7 @@ export function useMediaAssetActions() {
count: assetArray.length
}),
type: 'delete',
itemList: assetArray.map((asset) => asset.name),
itemList: assetArray.map((asset) => getAssetDisplayName(asset)),
onConfirm: async () => {
// Show loading overlay for all assets being deleted
assetArray.forEach((asset) =>

View File

@@ -14,7 +14,7 @@
<button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
@@ -193,7 +193,7 @@ const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
type ViewMode = 'gallery' | 'grid'

View File

@@ -19,12 +19,7 @@
v-if="activeItem"
:src="getItemSrc(activeItem)"
:alt="getItemAlt(activeItem, activeIndex)"
:class="
cn(
'h-auto w-full rounded-sm object-contain transition-opacity',
showControls && 'opacity-50'
)
"
class="h-auto w-full rounded-sm object-contain"
@load="handleImageLoad"
/>
@@ -238,7 +233,7 @@ const showNavButtons = computed(
)
const actionButtonClass =
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors hover:bg-base-foreground/90'
const toggleButtonClass = actionButtonClass

View File

@@ -23,10 +23,6 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function captureWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
function updateSelectedItems(selectedItems: Set<string>) {
const id =
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
@@ -36,7 +32,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
captureWorkflowState()
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
async function uploadFile(
@@ -109,7 +105,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
widget.callback(uploadedPaths[0])
}
captureWorkflowState()
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
}
)

View File

@@ -1,9 +1,9 @@
import * as Sentry from '@sentry/vue'
import _ from 'es-toolkit/compat'
import * as jsondiffpatch from 'jsondiffpatch'
import log from 'loglevel'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isDesktop } from '@/platform/distribution/types'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -20,14 +20,37 @@ function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
const logger = log.getLogger('ChangeTracker')
// Change to debug for more verbose logging
logger.setLevel('info')
function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
const reportedInactiveCalls = new Set<string>()
/**
* Report a ChangeTracker method being called on an inactive tracker —
* a lifecycle violation that usually indicates stale extension state or
* an incorrect call ordering. Reports once per method per workflow per
* session so the signal is not drowned out by hot-path invocations while
* still distinguishing between workflows.
*/
function reportInactiveTrackerCall(method: string, workflowPath: string) {
const key = `${method}:${workflowPath}`
if (reportedInactiveCalls.has(key)) return
reportedInactiveCalls.add(key)
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
if (isDesktop) {
Sentry.captureMessage(
`ChangeTracker.${method}() called on inactive tracker`,
{
level: 'warning',
tags: { workflow: workflowPath }
}
)
}
}
export class ChangeTracker {
static MAX_HISTORY = 50
/**
@@ -77,7 +100,6 @@ export class ChangeTracker {
// Do not reset the state if we are restoring.
if (this._restoringState) return
logger.debug('Reset State')
if (state) this.activeState = clone(state)
this.initialState = clone(this.activeState)
}
@@ -107,10 +129,7 @@ export class ChangeTracker {
*/
deactivate() {
if (!isActiveTracker(this)) {
logger.warn(
'deactivate() called on inactive tracker for:',
this.workflow.path
)
reportInactiveTrackerCall('deactivate', this.workflow.path)
return
}
if (!this._restoringState) this.captureCanvasState()
@@ -165,13 +184,6 @@ export class ChangeTracker {
this.initialState,
this.activeState
)
if (logger.getLevel() <= logger.levels.DEBUG && workflow.isModified) {
const diff = ChangeTracker.graphDiff(
this.initialState,
this.activeState
)
logger.debug('Graph diff:', diff)
}
}
}
@@ -181,19 +193,18 @@ export class ChangeTracker {
* Calling this on an inactive tracker would capture the wrong graph.
*/
captureCanvasState() {
const isUndoRedoing = this._restoringState
const isInsideChangeTransaction = this.changeCount > 0
if (
!app.graph ||
this.changeCount ||
this._restoringState ||
isInsideChangeTransaction ||
isUndoRedoing ||
ChangeTracker.isLoadingGraph
)
return
if (!isActiveTracker(this)) {
logger.warn(
'captureCanvasState called on inactive tracker for:',
this.workflow.path
)
reportInactiveTrackerCall('captureCanvasState', this.workflow.path)
return
}
@@ -207,7 +218,6 @@ export class ChangeTracker {
if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) {
this.undoQueue.shift()
}
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
this.activeState = currentState
this.redoQueue.length = 0
@@ -219,7 +229,7 @@ export class ChangeTracker {
checkState() {
if (!ChangeTracker._checkStateWarned) {
ChangeTracker._checkStateWarned = true
logger.warn(
console.warn(
'checkState() is deprecated — use captureCanvasState() instead.'
)
}
@@ -248,22 +258,10 @@ export class ChangeTracker {
async undo() {
await this.updateState(this.undoQueue, this.redoQueue)
logger.debug(
'Undo. Undo queue length:',
this.undoQueue.length,
'Redo queue length:',
this.redoQueue.length
)
}
async redo() {
await this.updateState(this.redoQueue, this.undoQueue)
logger.debug(
'Redo. Undo queue length:',
this.undoQueue.length,
'Redo queue length:',
this.redoQueue.length
)
}
async undoRedo(e: KeyboardEvent) {
@@ -337,7 +335,6 @@ export class ChangeTracker {
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(bindInputEl)) return
logger.debug('captureCanvasState on keydown')
changeTracker.captureCanvasState()
})
},
@@ -347,25 +344,21 @@ export class ChangeTracker {
window.addEventListener('keyup', () => {
if (keyIgnored) {
keyIgnored = false
logger.debug('captureCanvasState on keyup')
captureState()
}
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => {
logger.debug('captureCanvasState on mouseup')
captureState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => {
logger.debug('captureCanvasState on promptQueued')
captureState()
})
api.addEventListener('graphCleared', () => {
logger.debug('captureCanvasState on graphCleared')
captureState()
})
@@ -373,7 +366,6 @@ export class ChangeTracker {
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
logger.debug('captureCanvasState on processMouseUp')
captureState()
return v
}
@@ -390,7 +382,6 @@ export class ChangeTracker {
callback(v)
captureState()
}
logger.debug('captureCanvasState on prompt')
return prompt.apply(this, [title, value, extendedCallback, event])
}
@@ -398,7 +389,6 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
logger.debug('captureCanvasState on contextMenuClose')
captureState()
return v
}
@@ -501,25 +491,4 @@ export class ChangeTracker {
return false
}
private static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
function sortGraphNodes(graph: ComfyWorkflowJSON) {
return {
links: graph.links,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes,
groups: graph.groups,
extra: graph.extra,
definitions: graph.definitions,
subgraphs: graph.subgraphs,
nodes: graph.nodes.sort((a, b) => {
if (typeof a.id === 'number' && typeof b.id === 'number') {
return a.id - b.id
}
return 0
})
}
}
return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b))
}
}

View File

@@ -0,0 +1,43 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: { canvas: undefined },
ComfyApp: class {}
}))
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
describe('useLitegraphService().getCanvasCenter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns origin when canvas is not yet initialised', () => {
Reflect.set(app, 'canvas', undefined)
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns origin when canvas exists but ds.visible_area is missing', () => {
Reflect.set(app, 'canvas', { ds: {} })
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns the visible-area centre once the canvas is ready', () => {
Reflect.set(app, 'canvas', {
ds: { visible_area: [10, 20, 200, 100] }
})
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([110, 70])
})
})