Compare commits

..

4 Commits

Author SHA1 Message Date
Alexander Brown
07a5fbe16d Merge branch 'main' into litegraph/prune-lgraph-misc-hooks 2026-05-18 15:35:42 -07:00
Connor Byrne
dfeeb7b49a fix: mark unused beforeChange info param with underscore
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:25:19 -07:00
Alexander Brown
fcb7e01259 Merge branch 'main' into litegraph/prune-lgraph-misc-hooks 2026-05-13 18:24:35 -07:00
Connor Byrne
dad1eaddd5 refactor(litegraph): delete dead LGraph onGetNodeMenuOptions + onBeforeChange
Delete two remaining LGraph instance hook fields confirmed dead by
AUDIT-LG.9:

- LGraph.onGetNodeMenuOptions field + dispatcher in
  LGraphCanvas.ts (context-menu construction).
- LGraph.onBeforeChange field + dispatcher in LGraph.ts:1374.

LGraphCanvas.onBeforeChange is a SEPARATE field and is preserved.

Stacks on #12228. Closes the LGraph-side portion of #12224.
2026-05-13 16:47:49 -07:00
773 changed files with 4429 additions and 8288 deletions

View File

@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -28,7 +28,6 @@
],
"rules": {
"no-async-promise-executor": "off",
"func-style": ["error", "declaration"],
"no-console": [
"error",
{
@@ -125,12 +124,6 @@
"no-console": "allow"
}
},
{
"files": ["src/lib/litegraph/**"],
"rules": {
"func-style": "off"
}
},
{
"files": ["browser_tests/**/*.ts"],
"jsPlugins": ["eslint-plugin-playwright"],

View File

@@ -47,7 +47,7 @@ setup((app) => {
})
// Theme and dialog decorator
export function withTheme(Story: StoryFn, context: StoryContext) {
export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root

View File

@@ -40,7 +40,7 @@ setup((app) => {
app.use(ToastService)
})
export function withTheme(Story: StoryFn, context: StoryContext) {
export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
if (theme === 'dark') {
document.documentElement.classList.add('dark-theme')

View File

@@ -58,7 +58,7 @@ const tooltipText = computed(() => {
: t('serverStart.copyAllTooltip')
})
async function handleCopy() {
const handleCopy = async () => {
const existingSelection = terminal.getSelection()
const shouldSelectAll = !existingSelection
if (shouldSelectAll) terminal.selectAll()
@@ -76,7 +76,7 @@ async function handleCopy() {
}
}
function showContextMenu(event: MouseEvent) {
const showContextMenu = (event: MouseEvent) => {
event.preventDefault()
electronAPI()?.showContextMenu({ type: 'text' })
}

View File

@@ -44,9 +44,8 @@ const emit = defineEmits<{
const validationState = ref<ValidationState>(ValidationState.IDLE)
function cleanInput(value: string): string {
return value ? value.replace(/\s+/g, '') : ''
}
const cleanInput = (value: string): string =>
value ? value.replace(/\s+/g, '') : ''
// Add internal value state
const internalValue = ref(cleanInput(props.modelValue))
@@ -69,14 +68,14 @@ onMounted(async () => {
await validateUrl(props.modelValue)
})
function handleInput(value: string | undefined) {
const handleInput = (value: string | undefined) => {
// Update internal value without emitting
internalValue.value = cleanInput(value ?? '')
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}
async function handleBlur() {
const handleBlur = async () => {
const input = cleanInput(internalValue.value)
let normalizedUrl = input
@@ -92,7 +91,7 @@ async function handleBlur() {
}
// Default validation implementation
async function defaultValidateUrl(url: string): Promise<boolean> {
const defaultValidateUrl = async (url: string): Promise<boolean> => {
if (!isValidUrl(url)) return false
try {
return await checkUrlReachable(url)
@@ -101,7 +100,7 @@ async function defaultValidateUrl(url: string): Promise<boolean> {
}
}
async function validateUrl(value: string) {
const validateUrl = async (value: string) => {
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -127,7 +127,7 @@ const showDialog = ref(false)
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
function showMetricsInfo() {
const showMetricsInfo = () => {
showDialog.value = true
}
</script>

View File

@@ -182,12 +182,10 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
}
const userIsInChina = ref(false)
function useFallbackMirror(mirror: UVMirror) {
return {
...mirror,
mirror: mirror.fallbackMirror
}
}
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
@@ -214,7 +212,7 @@ onMounted(async () => {
userIsInChina.value = await isInChina()
})
async function validatePath(path: string | undefined) {
const validatePath = async (path: string | undefined) => {
try {
pathError.value = ''
pathExists.value = false
@@ -248,7 +246,7 @@ async function validatePath(path: string | undefined) {
}
}
async function browsePath() {
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {
@@ -260,7 +258,7 @@ async function browsePath() {
}
}
async function onFocus() {
const onFocus = async () => {
if (!inputTouched.value) {
inputTouched.value = true
return

View File

@@ -92,7 +92,7 @@ const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
async function validateSource(sourcePath: string | undefined) {
const validateSource = async (sourcePath: string | undefined) => {
if (!sourcePath) {
pathError.value = ''
return
@@ -109,7 +109,7 @@ async function validateSource(sourcePath: string | undefined) {
}
}
async function browsePath() {
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {

View File

@@ -82,7 +82,7 @@ const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
function toggle(event: Event) {
const toggle = (event: Event) => {
infoPopover.value?.toggle(event)
}
</script>

View File

@@ -67,7 +67,7 @@ defineProps<{
filter: MaintenanceFilter
}>()
async function executeTask(task: MaintenanceTask) {
const executeTask = async (task: MaintenanceTask) => {
let message: string | undefined
try {
@@ -87,7 +87,7 @@ async function executeTask(task: MaintenanceTask) {
}
// Commands
async function confirmButton(event: MouseEvent, task: MaintenanceTask) {
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
if (!task.requireConfirm) {
await executeTask(task)
return

View File

@@ -34,10 +34,10 @@ const buffer = useTerminalBuffer()
let xterm: Terminal | null = null
// Created and destroyed with the Drawer - contents copied from hidden buffer
function terminalCreated(
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) {
) => {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
terminal.write(props.defaultMessage)
@@ -49,7 +49,7 @@ function terminalCreated(
terminal.options.disableStdin = true
}
function terminalUnmounted() {
const terminalUnmounted = () => {
xterm = null
}

View File

@@ -55,14 +55,14 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
minRows?: number
onResize?: () => void
}) {
function ensureValidRows(rows: number | undefined): number {
const ensureValidRows = (rows: number | undefined): number => {
if (rows == null || isNaN(rows)) {
return (root.value?.clientHeight ?? 80) / 20
}
return rows
}
function ensureValidCols(cols: number | undefined): number {
const ensureValidCols = (cols: number | undefined): number => {
if (cols == null || isNaN(cols)) {
// Sometimes this is NaN if so, estimate.
return (root.value?.clientWidth ?? 80) / 8
@@ -70,7 +70,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
return cols
}
function resize() {
const resize = () => {
const dims = fitAddon.proposeDimensions()
// Sometimes propose returns NaN, so we may need to estimate.
terminal.resize(

View File

@@ -6,17 +6,13 @@ export function useTerminalBuffer() {
const serializeAddon = new SerializeAddon()
const terminal = markRaw(new Terminal({ convertEol: true }))
function copyTo(destinationTerminal: Terminal) {
const copyTo = (destinationTerminal: Terminal) => {
destinationTerminal.write(serializeAddon.serialize())
}
function write(message: string) {
return terminal.write(message)
}
const write = (message: string) => terminal.write(message)
function serialize() {
return serializeAddon.serialize()
}
const serialize = () => serializeAddon.serialize()
onMounted(() => {
terminal.loadAddon(serializeAddon)

View File

@@ -5,7 +5,7 @@ import { electronAPI } from '@/utils/envUtil'
const electron = electronAPI()
function openUrl(url: string) {
const openUrl = (url: string) => {
window.open(url, '_blank')
return true
}

View File

@@ -124,15 +124,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param task Task to get the matching state object for
* @returns The state object for this task
*/
function getRunner(task: MaintenanceTask) {
return taskRunners.value.get(task.id)!
}
const getRunner = (task: MaintenanceTask) => taskRunners.value.get(task.id)!
/**
* Updates the task list with the latest validation state.
* @param validationUpdate Update details passed in by electron
*/
function processUpdate(validationUpdate: InstallValidation) {
const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -153,19 +151,19 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
/** Clears the resolved status of tasks (when changing filters) */
function clearResolved() {
const clearResolved = () => {
for (const task of tasks.value) {
getRunner(task).resolved &&= false
}
}
/** @todo Refreshes Electron tasks only. */
async function refreshDesktopTasks() {
const refreshDesktopTasks = async () => {
isRefreshing.value = true
await electron.Validation.validateInstallation(processUpdate)
}
async function execute(task: MaintenanceTask) {
const execute = async (task: MaintenanceTask) => {
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()

View File

@@ -7,7 +7,7 @@ import { electronAPI } from './envUtil'
* @param mirror - The mirror to check.
* @returns True if the mirror is reachable, false otherwise.
*/
export async function checkMirrorReachable(mirror: string) {
export const checkMirrorReachable = async (mirror: string) => {
return (
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
)

View File

@@ -36,7 +36,7 @@ import { electronAPI } from '@/utils/envUtil'
const route = useRoute()
const { id, title, message, buttons } = getDialog(route.params.dialogId)
async function handleButtonClick(button: DialogAction) {
const handleButtonClick = async (button: DialogAction) => {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>

View File

@@ -52,7 +52,7 @@ const electron = electronAPI()
const terminalVisible = ref(false)
function toggleConsoleDrawer() {
const toggleConsoleDrawer = () => {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -47,11 +47,11 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
function openGitDownloads() {
const openGitDownloads = () => {
window.open('https://git-scm.com/downloads/', '_blank')
}
async function skipGit() {
const skipGit = async () => {
console.warn('pushing')
const router = useRouter()
await router.push('install')

View File

@@ -8,8 +8,8 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
// Create a mock router for stories
function createMockRouter() {
return createRouter({
const createMockRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
@@ -23,7 +23,6 @@ function createMockRouter() {
}
]
})
}
const meta: Meta<typeof InstallView> = {
title: 'Desktop/Views/InstallView',

View File

@@ -90,7 +90,7 @@ const currentStep = ref('1')
/** Forces each install step to be visited at least once. */
const highestStep = ref(0)
function handleStepChange(value: string | number) {
const handleStepChange = (value: string | number) => {
setHighestStep(value)
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -98,7 +98,7 @@ function handleStepChange(value: string | number) {
})
}
function setHighestStep(value: string | number) {
const setHighestStep = (value: string | number) => {
const int = typeof value === 'number' ? value : parseInt(value, 10)
if (!isNaN(int) && int > highestStep.value) highestStep.value = int
}
@@ -123,7 +123,7 @@ const canProceed = computed(() => {
})
// Navigation methods
function goToNextStep() {
const goToNextStep = () => {
const nextStep = (parseInt(currentStep.value) + 1).toString()
currentStep.value = nextStep
setHighestStep(nextStep)
@@ -132,7 +132,7 @@ function goToNextStep() {
})
}
function goToPreviousStep() {
const goToPreviousStep = () => {
const prevStep = (parseInt(currentStep.value) - 1).toString()
currentStep.value = prevStep
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -142,7 +142,7 @@ function goToPreviousStep() {
const electron = electronAPI()
const router = useRouter()
async function install() {
const install = async () => {
if (!device.value) return
const options: InstallOptions = {

View File

@@ -35,14 +35,12 @@ const validationState: ValidationState = {
upgradePackages: 'OK'
}
function createMockElectronAPI() {
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
function getValidationUpdate() {
return {
...validationState
}
}
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
@@ -78,7 +76,7 @@ function createMockElectronAPI() {
}
}
function ensureElectronAPI() {
const ensureElectronAPI = () => {
const globalWindow = window as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()

View File

@@ -183,7 +183,7 @@ const unsafeReasonText = computed(() => {
})
/** If valid, leave the validation window. */
async function completeValidation() {
const completeValidation = async () => {
const isValid = await electron.Validation.complete()
if (!isValid) {
toast.add({
@@ -194,7 +194,7 @@ async function completeValidation() {
}
}
function toggleConsoleDrawer() {
const toggleConsoleDrawer = () => {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -67,9 +67,7 @@ const electron = electronAPI()
const basePath = ref<string | null>(null)
const sep = ref<'\\' | '/'>('/')
function restartApp(message?: string) {
return electron.restartApp(message)
}
const restartApp = (message?: string) => electron.restartApp(message)
onMounted(async () => {
basePath.value = await electron.getBasePath()

View File

@@ -64,7 +64,7 @@ const allowMetrics = ref(true)
const router = useRouter()
const isUpdating = ref(false)
async function updateConsent() {
const updateConsent = async () => {
isUpdating.value = true
try {
await electronAPI().setMetricsConsent(allowMetrics.value)

View File

@@ -61,19 +61,19 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
function openDocs() {
const openDocs = () => {
window.open(
'https://github.com/Comfy-Org/desktop#currently-supported-platforms',
'_blank'
)
}
function reportIssue() {
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const router = useRouter()
async function continueToInstall() {
const continueToInstall = async () => {
await router.push('/install')
}
</script>

View File

@@ -118,7 +118,7 @@ let xterm: Terminal | undefined
/**
* Handles installation stage updates from the desktop
*/
function updateInstallStage(stageInfo: InstallStageInfo) {
const updateInstallStage = (stageInfo: InstallStageInfo) => {
console.warn('[InstallStage.onUpdate] Received:', {
stage: stageInfo.stage,
progress: stageInfo.progress,
@@ -183,17 +183,17 @@ const displayStatusText = computed(() => {
return currentStatusLabel.value
})
function updateProgress({ status: newStatus }: { status: ProgressStatus }) {
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
status.value = newStatus
// Make critical error screen more obvious.
if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false
}
function terminalCreated(
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) {
) => {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
@@ -206,15 +206,11 @@ function terminalCreated(
terminal.options.cursorInactiveStyle = 'block'
}
function troubleshoot() {
return electron.startTroubleshooting()
}
function reportIssue() {
const troubleshoot = () => electron.startTroubleshooting()
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
function openLogs() {
return electron.openLogsFolder()
}
const openLogs = () => electron.openLogsFolder()
let cleanupInstallStageListener: (() => void) | undefined

View File

@@ -33,7 +33,7 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const router = useRouter()
async function navigateTo(path: string) {
const navigateTo = async (path: string) => {
await router.push(path)
}
</script>

View File

@@ -3,9 +3,8 @@ import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {

View File

@@ -111,7 +111,7 @@ async function measureMarqueeLoopGeometry(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
function setAllTimes(time: number) {
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
@@ -119,9 +119,7 @@ async function measureMarqueeLoopGeometry(
}
void document.body.offsetWidth
}
function readX() {
return tracks.map((track) => track.getBoundingClientRect().x)
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(

View File

@@ -1,14 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -36,9 +36,7 @@ let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
function deptElementId(key: string) {
return `careers-dept-${key}`
}
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0

View File

@@ -58,7 +58,7 @@ onMounted(() => {
})
raw.sort((a, b) => {
function norm(v: number) {
const norm = (v: number) => {
const r = v + Math.PI / 2
return r < 0 ? r + 2 * Math.PI : r
}
@@ -117,7 +117,7 @@ onMounted(() => {
applyToPanel(panels[1], elapsed2)
applyToPanel(panels[2], elapsed3)
function wOf(elapsed: number) {
const wOf = (elapsed: number) => {
const progress = elapsed < PANEL_DURATION ? elapsed / PANEL_DURATION : 1
return lerp(S.w, E.w, ease(progress))
}

View File

@@ -35,7 +35,7 @@ export function useParallax(
const triggerEl = options.trigger?.value
function createAnimations() {
const createAnimations = () => {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)

View File

@@ -29,7 +29,7 @@ function interpolateY(
contentH: number,
vpH: number
) {
function clampedTarget(i: number) {
const clampedTarget = (i: number) => {
const center = buttonCenters[i] ?? 0
return Math.max(-(contentH - vpH), Math.min(0, vpH / 2 - center))
}

View File

@@ -71,7 +71,7 @@ const websiteJsonLd = {
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />

View File

@@ -1,4 +1,5 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../data/cloudNodes'
@@ -8,7 +9,7 @@ import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },

View File

@@ -1,4 +1,5 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
import ContentSection from '../../components/common/ContentSection.vue'
@@ -6,7 +7,7 @@ import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
import { t } from '../../i18n/translations'
export function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = () => {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,4 +1,5 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
@@ -7,7 +8,7 @@ import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
export function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

@@ -1,10 +1,11 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
export function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
params: { slug: model.slug }
}))

View File

@@ -1,4 +1,5 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../../data/cloudNodes'
@@ -8,7 +9,7 @@ import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },

View File

@@ -1,4 +1,5 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
import ContentSection from '../../../components/common/ContentSection.vue'
@@ -6,7 +7,7 @@ import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
import { t } from '../../../i18n/translations'
export function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = () => {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,4 +1,5 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
@@ -7,7 +8,7 @@ import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
export function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

@@ -35,9 +35,8 @@ const TICK_MS = 200
function readColors() {
const style = getComputedStyle(document.documentElement)
function get(name: string, fallback: string): string {
return style.getPropertyValue(name).trim() || fallback
}
const get = (name: string, fallback: string): string =>
style.getPropertyValue(name).trim() || fallback
return {
bg: get('--color-primary-comfy-ink', '#211927'),
@@ -60,12 +59,9 @@ function requireElement<T extends Element>(
return el
}
function isSVGSVG(el: Element): el is SVGSVGElement {
return el instanceof SVGSVGElement
}
function isSVGG(el: Element): el is SVGGElement {
return el instanceof SVGGElement
}
const isSVGSVG = (el: Element): el is SVGSVGElement =>
el instanceof SVGSVGElement
const isSVGG = (el: Element): el is SVGGElement => el instanceof SVGGElement
function isSVGText(el: Element): el is SVGTextElement {
return el instanceof SVGTextElement
}
@@ -131,9 +127,8 @@ function depth(cell: Cell): number {
function roundedPath(pts: [number, number][], radius: number): string {
const n = pts.length
function fmt(p: readonly [number, number]) {
return `${p[0].toFixed(2)},${p[1].toFixed(2)}`
}
const fmt = (p: readonly [number, number]) =>
`${p[0].toFixed(2)},${p[1].toFixed(2)}`
let d = ''
for (let i = 0; i < n; i++) {
const prev = pts[(i - 1 + n) % n]
@@ -211,7 +206,7 @@ function triggerExplosion() {
const cx = ((COLS - ROWS) * STEP_X) / 2
const cy = ((COLS + ROWS - 2) * STEP_Y) / 2
function launchParticle(i: number, j: number, fill: string): Particle {
const launchParticle = (i: number, j: number, fill: string): Particle => {
const [sx, sy] = iso(i, j)
const baseAngle = Math.atan2(sy - cy, sx - cx)
const angle = baseAngle + (Math.random() - 0.5) * 1.2
@@ -244,9 +239,7 @@ function triggerExplosion() {
const DROP_DURATION_MS = 450
const DROP_HEIGHT = 600
let foodDropStart = 0
function easeOutCubic(t: number) {
return 1 - Math.pow(1 - t, 3)
}
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
function foodDropOffset(now = performance.now()): number {
if (!foodDropStart) return 0
@@ -259,7 +252,7 @@ function foodDropOffset(now = performance.now()): number {
const REBIRTH_STAGGER_MS = 90
const REBIRTH_GROW_MS = 260
let rebirthStart = 0
function easeOutBack(t: number) {
const easeOutBack = (t: number) => {
const c1 = 1.70158
const c3 = c1 + 1
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
@@ -278,9 +271,7 @@ function rebirthScaleFor(idx: number, now = performance.now()): number {
const CHOMP_DURATION_MS = 220
const CHOMP_PEAK_SCALE = 1.15
let chompStart = 0
function easeOut(t: number) {
return 1 - (1 - t) * (1 - t)
}
const easeOut = (t: number) => 1 - (1 - t) * (1 - t)
function chompScale(now = performance.now()): number {
if (!chompStart) return 1
@@ -308,7 +299,7 @@ function isAnimating(): boolean {
function ensureAnimationLoop() {
if (animationHandle !== null) return
function tick() {
const tick = () => {
if (
explodeStart &&
performance.now() - explodeStart >= EXPLODE_DURATION_MS
@@ -420,12 +411,8 @@ function updateScoreDisplay() {
scoreBestEl.textContent = String(best)
}
function cellsEqual(a: Cell, b: Cell) {
return a.i === b.i && a.j === b.j
}
function inBounds(c: Cell) {
return c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
}
const cellsEqual = (a: Cell, b: Cell) => a.i === b.i && a.j === b.j
const inBounds = (c: Cell) => c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
function reset() {
const j0 = Math.floor(ROWS / 2)

View File

@@ -5,13 +5,11 @@ import { TestIds } from '@e2e/fixtures/selectors'
export class QueuePanel {
readonly overlayToggle: Locator
readonly overlay: Locator
readonly moreOptionsButton: Locator
constructor(readonly page: Page) {
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
this.moreOptionsButton = page.getByLabel(/More options/i).first()
}
async openClearHistoryDialog() {

View File

@@ -158,7 +158,7 @@ export class AssetHelper {
statusCode: number,
error: string = 'Internal Server Error'
): Promise<void> {
async function handler(route: Route) {
const handler = async (route: Route) => {
return route.fulfill({
status: statusCode,
json: { error }

View File

@@ -325,7 +325,7 @@ export class AssetsHelper {
await this.page.unroute(pattern, existingHandler)
}
async function handler(route: Route) {
const handler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -1,26 +1,19 @@
import { test as base, expect } from '@playwright/test'
import type { Page, Route, WebSocketRoute } from '@playwright/test'
import { test as base } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { LogsRawResponse } from '@/schemas/apiSchema'
const RAW_LOGS_URL = '**/internal/logs/raw**'
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
export class LogsTerminalHelper {
constructor(private readonly page: Page) {}
async mockRawLogs(messages: string[]): Promise<() => number> {
let count = 0
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({
async mockRawLogs(messages: string[]) {
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
})
})
return () => count
)
}
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
@@ -28,8 +21,7 @@ export class LogsTerminalHelper {
const pending = new Promise<void>((r) => {
resolve = r
})
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
await pending
await route.fulfill({
status: 200,
@@ -41,39 +33,15 @@ export class LogsTerminalHelper {
}
async mockRawLogsError() {
await this.page.unroute(RAW_LOGS_URL)
await this.page.route(RAW_LOGS_URL, (route: Route) =>
await this.page.route('**/internal/logs/raw**', (route: Route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
}
async mockSubscribeLogs(): Promise<() => number> {
let count = 0
await this.page.unroute(SUBSCRIBE_LOGS_URL)
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
count += 1
await route.fulfill({ status: 200, body: '' })
})
return () => count
}
/**
* Force the frontend to reconnect by closing the proxied WebSocket. The
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
* handler fires again, and on `open` with `isReconnect=true` it dispatches
* `'reconnected'`, which triggers the logs-terminal resync.
*
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
* the time the count goes up, the new socket is open and resync has
* completed enough to assert against the terminal.
*/
async triggerReconnect(
ws: WebSocketRoute,
subscribeFetches: () => number
): Promise<void> {
const before = subscribeFetches()
await ws.close()
await expect.poll(subscribeFetches).toBeGreaterThan(before)
async mockSubscribeLogs() {
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
route.fulfill({ status: 200, body: '' })
)
}
static buildWsLogFrame(messages: string[]): string {

View File

@@ -42,7 +42,7 @@ export async function setupNodeReplacement(
options?: AddEventListenerOptions | boolean
) {
if (type === 'message' && typeof listener === 'function') {
function wrapped(this: WebSocket, event: Event) {
const wrapped = function (this: WebSocket, event: Event) {
const msgEvent = event as MessageEvent
if (typeof msgEvent.data === 'string') {
try {

View File

@@ -11,7 +11,6 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
@@ -242,17 +241,6 @@ export class SubgraphHelper {
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
}
async getInputBounds(): Promise<Position & Size> {
return await this.comfyPage.page.evaluate(() => {
const graph = app!.canvas.graph as Subgraph
const inputNode = graph.inputNode
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
const width = inputNode.size[0] * app!.canvas.ds.scale
const height = inputNode.size[1] * app!.canvas.ds.scale
return { x, y, width, height }
})
}
/**
* Connect a regular node output to a subgraph input.
* This creates a new input slot on the subgraph if targetInputName is not provided.
@@ -618,7 +606,7 @@ export class SubgraphHelper {
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
function handler(msg: ConsoleMessage) {
const handler = (msg: ConsoleMessage) => {
const text = msg.text()
if (patterns.some((p) => text.includes(p))) {
warnings.push(text)

View File

@@ -150,7 +150,7 @@ export class TemplateHelper {
}
async mockThumbnails(): Promise<void> {
async function thumbnailHandler(route: Route) {
const thumbnailHandler = async (route: Route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',

View File

@@ -1,11 +1,6 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { z } from 'zod'
import {
zHistoryManageRequest,
zQueueManageRequest,
zQueueManageResponse
} from '@comfyorg/ingest-types/zod'
import type {
JobStatus,
@@ -14,8 +9,6 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
type JobsListResponse = z.infer<typeof zJobsListResponse>
type HistoryManageRequest = z.infer<typeof zHistoryManageRequest>
type QueueManageRequest = z.infer<typeof zQueueManageRequest>
const terminalJobStatuses = [
'completed',
@@ -29,8 +22,7 @@ const activeJobStatuses = [
const defaultJobsListLimit = 200
const defaultScenarioHistoryLimit = 64
const defaultJobsListOffset = 0
export const routeMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
const defaultRouteMockJobTimestamp = Date.UTC(2026, 0, 1, 12)
interface JobsListRoute {
statuses: readonly JobStatus[]
@@ -40,7 +32,7 @@ interface JobsListRoute {
responseLimit?: number
}
export interface JobsScenario {
interface JobsScenario {
history?: readonly RawJobListItem[]
queue?: readonly RawJobListItem[]
}
@@ -73,9 +65,11 @@ function hasJobsListPageParams(
)
}
function matchesJobsListRoute(url: URL, route: JobsListRoute): boolean {
function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
return (
hasExactStatuses(url, route.statuses) && hasJobsListPageParams(url, route)
url.pathname.endsWith('/api/jobs') &&
hasExactStatuses(url, route.statuses) &&
hasJobsListPageParams(url, route)
)
}
@@ -105,9 +99,9 @@ export function createRouteMockJob({
return {
id,
status: 'completed',
create_time: routeMockJobTimestamp,
execution_start_time: routeMockJobTimestamp,
execution_end_time: routeMockJobTimestamp + 5_000,
create_time: defaultRouteMockJobTimestamp,
execution_start_time: defaultRouteMockJobTimestamp,
execution_end_time: defaultRouteMockJobTimestamp + 5_000,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
@@ -156,8 +150,7 @@ export class JobsRouteMocker {
const response = createJobsListResponse(route)
await this.page.route(
(url) =>
url.pathname.endsWith('/api/jobs') && matchesJobsListRoute(url, route),
(url) => isJobsListRequest(url, route),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
@@ -168,44 +161,6 @@ export class JobsRouteMocker {
}
)
}
async mockClearQueue(): Promise<QueueManageRequest[]> {
const response = zQueueManageResponse.parse({ cleared: true })
return await this.mockPostManageRoute(
'queue',
zQueueManageRequest,
response
)
}
async mockClearHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,
response: unknown
): Promise<TRequest[]> {
const requests: TRequest[] = []
await this.page.route(
(url) => url.pathname.endsWith(`/api/${type}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'POST') {
await requestRoute.fallback()
return
}
requests.push(
requestSchema.parse(requestRoute.request().postDataJSON())
)
await requestRoute.fulfill({ json: response })
}
)
return requests
}
}
export const jobsRouteFixture = base.extend<{
@@ -213,5 +168,6 @@ export const jobsRouteFixture = base.extend<{
}>({
jobsRoutes: async ({ page }, use) => {
await use(new JobsRouteMocker(page))
await page.unrouteAll({ behavior: 'wait' })
}
})

View File

@@ -76,15 +76,7 @@ export const TestIds = {
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog',
openSharedWorkflow: 'open-shared-workflow-dialog',
openSharedWorkflowTitle: 'open-shared-workflow-title',
openSharedWorkflowClose: 'open-shared-workflow-close',
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
openSharedWorkflowOpenWithoutImporting:
'open-shared-workflow-open-without-importing',
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
cloudNotification: 'cloud-notification-dialog'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -234,10 +226,7 @@ export const TestIds = {
currentUserIndicator: 'current-user-indicator'
},
queue: {
jobHistorySidebar: 'job-history-sidebar',
progressOverlay: 'queue-progress-overlay',
overlayToggle: 'queue-overlay-toggle',
dockedJobHistoryAction: 'docked-job-history-action',
jobDetailsPopover: 'queue-job-details-popover',
clearHistoryAction: 'clear-history-action',
jobAssetsList: 'job-assets-list',

View File

@@ -1,252 +0,0 @@
import { test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
Asset,
ImportPublishedAssetsRequest,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { z } from 'zod'
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import type { AssetInfo } from '@/schemas/apiSchema'
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
export const sharedWorkflowImportScenario = {
shareId: 'shared-missing-media-e2e',
workflowId: 'shared-missing-media-workflow',
publishedAssetId: 'published-input-asset-1',
inputFileName: 'shared_imported_image.png'
} as const
export type SharedWorkflowRequestEvent =
| 'import'
| 'input-assets-including-public-before-import'
| 'input-assets-including-public-after-import'
export interface SharedWorkflowImportMocks {
resetAndStartRecording: () => void
getImportBody: () => ImportPublishedAssetsRequest | undefined
getRequestEvents: () => SharedWorkflowRequestEvent[]
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
}
const defaultInputFileName = '00000000000000000000000Aexample.png'
const sharedWorkflowAsset: AssetInfo = {
id: sharedWorkflowImportScenario.publishedAssetId,
name: sharedWorkflowImportScenario.inputFileName,
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
}
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const sharedWorkflowResponse: SharedWorkflowResponse = {
share_id: sharedWorkflowImportScenario.shareId,
workflow_id: sharedWorkflowImportScenario.workflowId,
name: 'Shared Missing Media Workflow',
listed: true,
publish_time: '2026-05-01T00:00:00Z',
workflow_json: {
version: 0.4,
last_node_id: 10,
last_link_id: 0,
nodes: [
{
id: 10,
type: 'LoadImage',
pos: [50, 200],
size: [315, 314],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'IMAGE',
type: 'IMAGE',
links: null
},
{
name: 'MASK',
type: 'MASK',
links: null
}
],
properties: {
'Node name for S&R': 'LoadImage'
},
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
}
],
links: [],
groups: [],
config: {},
extra: {
ds: {
offset: [0, 0],
scale: 1
}
}
},
assets: [sharedWorkflowAsset]
}
export const sharedWorkflowImportFixture = base.extend<{
sharedWorkflowImportMocks: SharedWorkflowImportMocks
}>({
sharedWorkflowImportMocks: async ({ page }, use) => {
const mocks = await mockSharedWorkflowImportFlow(page)
await use(mocks)
}
})
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
function noopResolveResponse() {}
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void =
noopResolveResponse
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
const requestEvents: SharedWorkflowRequestEvent[] = []
function resetPublicInclusiveInputAssetResponseWaiter() {
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
}
)
}
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
if (isRecording) requestEvents.push(event)
}
await page.route(
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(sharedWorkflowResponse)
})
}
)
await page.route('**/api/assets/import', async (route) => {
recordRequestEvent('import')
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
importEndpointCalled = true
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
})
// Excludes `/api/assets/import` so the specific route above
// remains isolated from the general asset listing mock.
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const url = new URL(route.request().url())
const includeTags = getTagParam(url, 'include_tags')
const isInputAssetRequest = includeTags.includes('input')
const includesPublicAssets =
url.searchParams.get('include_public') === 'true'
const isPublicInclusiveInputAssetRequest =
isInputAssetRequest && includesPublicAssets
const isAfterImportPublicInclusiveInputAssetRequest =
isPublicInclusiveInputAssetRequest && importEndpointCalled
if (isPublicInclusiveInputAssetRequest) {
recordRequestEvent(
importEndpointCalled
? 'input-assets-including-public-after-import'
: 'input-assets-including-public-before-import'
)
}
const allAssets = [
defaultInputAsset,
...(importEndpointCalled ? [importedInputAsset] : [])
]
const assets = includeTags.length
? allAssets.filter((asset) =>
includeTags.every((tag) => asset.tags?.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
if (isAfterImportPublicInclusiveInputAssetRequest) {
resolvePublicInclusiveInputAssetResponseAfterImport()
}
})
return {
resetAndStartRecording: () => {
isRecording = true
importEndpointCalled = false
importBody = undefined
requestEvents.length = 0
resetPublicInclusiveInputAssetResponseWaiter()
},
getImportBody: () => importBody,
getRequestEvents: () => [...requestEvents],
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
publicInclusiveInputAssetResponseAfterImport
}
}
function getTagParam(url: URL, key: string): string[] {
return (
url.searchParams
.get(key)
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}

View File

@@ -7,7 +7,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export function getMiddlePoint(pos1: Position, pos2: Position) {
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
return {
x: (pos1.x + pos2.x) / 2,
y: (pos1.y + pos2.y) / 2

View File

@@ -1,28 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export async function openMoreOptionsMenu(
comfyPage: ComfyPage,
nodeTitle: string
) {
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
if (nodes.length === 0) {
throw new Error(`No "${nodeTitle}" nodes found`)
}
await nodes[0].centerOnNode()
await nodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible()
return menu
}

View File

@@ -45,7 +45,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
// Find and set the width on the latent node
async function triggerChange(value: number) {
const triggerChange = async (value: number) => {
return await comfyPage.page.evaluate((value) => {
const node = window.app!.graph!._nodes.find(
(n) => n.type === 'EmptyLatentImage'
@@ -59,7 +59,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Trigger a status websocket message
function triggerStatus(queueSize: number) {
const triggerStatus = (queueSize: number) => {
ws.send(
JSON.stringify({
type: 'status',
@@ -75,7 +75,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
}
// Extract the width from the queue response
async function getQueuedWidth(resp: Promise<Response>) {
const getQueuedWidth = async (resp: Promise<Response>) => {
const obj = await (await resp).json()
return obj['__request']['prompt']['5']['inputs']['width']
}

View File

@@ -147,68 +147,5 @@ test.describe('Bottom Panel Logs', { tag: '@ui' }, () => {
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toBeHidden()
})
test('resyncs the terminal when the WebSocket reconnects', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
const initialLine = 'pre-reboot log line'
const postRebootLineA = 'post-reboot line A'
const postRebootLineB = 'post-reboot line B'
await logsTerminal.mockRawLogs([initialLine])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
initialLine
)
// Swap the raw-logs mock so the next fetch returns the post-reboot view.
await logsTerminal.mockRawLogs([postRebootLineA, postRebootLineB])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineA
)
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
postRebootLineB
)
// reset() before write means the pre-reboot line must be gone.
await expect(comfyPage.bottomPanel.logs.terminalRoot).not.toContainText(
initialLine
)
})
test('resumes WebSocket log streaming after the reconnect', async ({
comfyPage,
logsTerminal,
getWebSocket
}) => {
const subscribeFetches = await logsTerminal.mockSubscribeLogs()
await logsTerminal.mockRawLogs(['initial'])
await comfyPage.bottomPanel.toggleLogs()
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
'initial'
)
await logsTerminal.mockRawLogs(['after-reboot snapshot'])
const ws = await getWebSocket()
await logsTerminal.triggerReconnect(ws, subscribeFetches)
// The route handler fires again on the new connection; pull the latest
// WebSocketRoute and push a live frame to prove the 'logs' listener
// survived the reconnect.
const liveLine = 'live log emitted after the reconnect'
const newWs = await getWebSocket()
newWs.send(LogsTerminalHelper.buildWsLogFrame([liveLine]))
await expect(comfyPage.bottomPanel.logs.terminalRoot).toContainText(
liveLine
)
})
})
})

View File

@@ -5,18 +5,16 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
function expectedGroupSize(
const expectedGroupSize = (
nodeBounds: Size,
padding: number,
titleHeight: number
): Size {
return {
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
}
}
): Size => ({
width: nodeBounds.width + padding * 2,
// Group height adds one title row above the contained node bounds (which
// themselves already include the node's own title), independent of padding.
height: nodeBounds.height + padding * 2 + titleHeight
})
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
@@ -26,7 +24,7 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.nodeOps.clearGraph()
})
async function createNode(comfyPage: ComfyPage) {
const createNode = async (comfyPage: ComfyPage) => {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
@@ -81,10 +79,10 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function groupAroundAllNodesWithPadding(
const groupAroundAllNodesWithPadding = async (
comfyPage: ComfyPage,
padding: number
): Promise<Size> {
): Promise<Size> => {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
@@ -128,16 +126,15 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
function litegraphContextMenu(comfyPage: ComfyPage) {
return comfyPage.page.locator('.litecontextmenu')
}
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
async function openComboMenu(comfyPage: ComfyPage) {
const openComboMenu = async (comfyPage: ComfyPage) => {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]

View File

@@ -3,14 +3,12 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
function getLocators(page: Page) {
return {
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
}
}
const getLocators = (page: Page) => ({
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
})
const MODES = [
{

View File

@@ -7,7 +7,7 @@ import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
async function getClipNodesDragBox(comfyPage: ComfyPage) {
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(
clipNodes,
@@ -242,11 +242,11 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
* because it exists only to probe the CanvasPointer timing thresholds.
*/
async function holdDragAt(
const holdDragAt = async (
comfyPage: ComfyPage,
pos: { x: number; y: number },
opts: { dx: number; dy: number; holdMs: number }
) {
) => {
const abs = await comfyPage.canvasOps.toAbsolute(pos)
await comfyPage.page.mouse.move(abs.x, abs.y)
await comfyPage.page.mouse.down()
@@ -383,9 +383,8 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
// render-loop throttle value instead — that is what actually governs
// frame cadence.
function getFrameGap(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
}
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
test('caps the render loop frame gap', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)

View File

@@ -219,7 +219,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
async function bypassAndPin() {
const bypassAndPin = async () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
@@ -228,14 +228,14 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await afterChange(comfyPage)
}
async function collapse() {
const collapse = async () => {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
async function multipleChanges() {
const multipleChanges = async () => {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')

View File

@@ -133,11 +133,10 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await node.click('title')
// Normal mode is ALWAYS (0)
function getMode() {
return comfyPage.page.evaluate((nodeId) => {
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
}
await expect.poll(() => getMode()).toBe(0)

View File

@@ -290,7 +290,7 @@ test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
const confirmDialog = comfyPage.confirmDialog.root
const { incrementButton } = comfyPage.vueNodes.getInputNumberControls(steps)
async function dirtyGraphAndSave() {
const dirtyGraphAndSave = async () => {
await incrementButton.click()
await comfyPage.page.keyboard.press('Control+s')
}

View File

@@ -21,14 +21,13 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
function getGroupPositions() {
return comfyPage.page.evaluate(() =>
const getGroupPositions = () =>
comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
}
await expect.poll(getGroupPositions).toHaveLength(2)

View File

@@ -137,7 +137,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
async function makeGroup(name: string, type1: string, type2: string) {
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
@@ -204,7 +204,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
async function expectSingleNode(type: string) {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
@@ -255,13 +255,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
async function isRegisteredLitegraph(comfyPage: ComfyPage) {
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window.LiteGraph!.registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
async function isRegisteredNodeDefStore(comfyPage: ComfyPage) {
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
await comfyPage.menu.nodeLibraryTab.open()
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
@@ -269,10 +269,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
return groupNodesFolderCt === 1
}
async function verifyNodeLoaded(
const verifyNodeLoaded = async (
comfyPage: ComfyPage,
expectedCount: number
) {
) => {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)
@@ -361,15 +361,3 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
await comfyPage.vueNodes.enterSubgraph()
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
})

View File

@@ -510,7 +510,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
const brokenAfter = 'http://127.0.0.1:1/broken2.png'
const pageErrors: Error[] = []
function onPageError(err: Error) {
const onPageError = (err: Error) => {
pageErrors.push(err)
}
comfyPage.page.on('pageerror', onPageError)

View File

@@ -82,10 +82,10 @@ test.describe('Node Interaction', () => {
}
)
async function dragSelectNodes(
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) {
) => {
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -117,16 +117,15 @@ test.describe('Node Interaction', () => {
}) => {
const clipNodes =
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
function getPositions() {
return Promise.all(clipNodes.map((node) => node.getPosition()))
}
async function testDirection({
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) {
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.command.executeCommand(
@@ -672,7 +671,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
})
test('Cursor style changes when panning', async ({ comfyPage }) => {
async function getCursorStyle() {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
@@ -704,7 +703,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
test('Properly resets dragging state after pan mode sequence', async ({
comfyPage
}) => {
async function getCursorStyle() {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
@@ -879,9 +878,8 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
function generateUniqueFilename(extension = '') {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
}
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
@@ -1079,7 +1077,7 @@ test.describe('Viewport settings', () => {
comfyPage,
comfyMouse
}) => {
async function changeTab(tab: Locator) {
const changeTab = async (tab: Locator) => {
await tab.click()
await comfyPage.nextFrame()
await comfyMouse.move(DefaultGraphPositions.emptySpace)
@@ -1408,7 +1406,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
test('Cursor changes appropriately in different modes', async ({
comfyPage
}) => {
async function getCursorStyle() {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'

View File

@@ -3,15 +3,14 @@ import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
function getGizmoConfig(page: Page) {
return page.evaluate(() => {
const getGizmoConfig = (page: Page) =>
page.evaluate(() => {
const n = window.app!.graph.getNodeById(1)
const modelConfig = n?.properties?.['Model Config'] as
| { gizmo?: { enabled: boolean; mode: string } }
| undefined
return modelConfig?.gizmo
})
}
test.describe('Load3D Gizmo Controls', () => {
test(

View File

@@ -155,7 +155,7 @@ test.describe('Load3D', () => {
async ({ comfyPage, load3d }) => {
await expect(load3d.uploadBackgroundImageButton).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById(1)
async function readBackgroundImage() {
const readBackgroundImage = async () => {
const properties =
await node.getProperty<Record<string, { backgroundImage?: string }>>(
'properties'
@@ -222,7 +222,7 @@ test.describe('Load3D', () => {
await expect(load3d.gridToggleButton).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById(1)
async function readShowGrid() {
const readShowGrid = async () => {
const properties =
await node.getProperty<Record<string, { showGrid?: boolean }>>(
'properties'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,65 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.describe(
'Node context menu shape submenu (FE-570)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
const popover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(popover).toBeVisible()
await expect(popover).toContainText('Box')
await expect(popover).toContainText('Card')
const popoverBox = await popover.boundingBox()
expect(popoverBox).not.toBeNull()
expect(popoverBox!.width).toBeGreaterThan(0)
expect(popoverBox!.height).toBeGreaterThan(0)
}
test('Shape popover opens when the menu fits in the viewport', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
.toBe('visible')
await menu.getByRole('menuitem', { name: 'Shape' }).click()
await expectShapePopoverVisible(comfyPage)
})
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')
await expect
.poll(() =>
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
)
.toBe(true)
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
await shapeItem.scrollIntoViewIfNeeded()
await shapeItem.click()
await expectShapePopoverVisible(comfyPage)
})
}
)

View File

@@ -55,7 +55,7 @@ async function setLocaleAndWaitForWorkflowReload(
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
function tick() {
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}

View File

@@ -166,10 +166,10 @@ test.describe('Node search box', { tag: '@node' }, () => {
})
test.describe('Filtering', () => {
async function expectFilterChips(
const expectFilterChips = async (
comfyPage: ComfyPage,
expectedTexts: string[]
) {
) => {
const chips = comfyPage.searchBox.filterChips
// Check that the number of chips matches the expected count

View File

@@ -243,18 +243,15 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
function switchToDesktop() {
return comfyPage.page.setViewportSize({ width: 1280, height: 800 })
}
function switchToMobile() {
return comfyPage.page.setViewportSize({ width: 360, height: 800 })
}
function expectExpanded(value: 'true' | 'false') {
return expect(searchBoxV2.sidebarToggle).toHaveAttribute(
const switchToDesktop = () =>
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
const switchToMobile = () =>
comfyPage.page.setViewportSize({ width: 360, height: 800 })
const expectExpanded = (value: 'true' | 'false') =>
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
'aria-expanded',
value
)
}
await switchToDesktop()
await searchBoxV2.open()

View File

@@ -312,9 +312,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
function getCount() {
return searchBoxV2.results.count()
}
const getCount = () => searchBoxV2.results.count()
await searchBoxV2.open()

View File

@@ -758,8 +758,8 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
await comfyPage.nextFrame()
function hasContentAtRow(yFraction: number) {
return canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
@@ -769,7 +769,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
}
return false
}, yFraction)
}
await expect
.poll(() => hasContentAtRow(0.25), {

View File

@@ -1,41 +0,0 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName)
await expect(comfyPage.searchBox.input).toBeHidden()
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})

View File

@@ -77,7 +77,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}
cloudUploadAssetStateByPage.set(page, state)
async function assetsRouteHandler(route: Route) {
const assetsRouteHandler = async (route: Route) => {
const allAssets = [
cloudDefaultGraphInputAsset,
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
@@ -149,7 +149,7 @@ async function delayNextUpload(comfyPage: ComfyPage) {
releaseUpload = resolve
})
async function uploadRouteHandler(route: Route) {
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
await route.continue()

View File

@@ -15,9 +15,7 @@ const REQUEST_ID_SECONDARY = 2
const REQUEST_ID_MISMATCH = 999
let nextRequestId = 1000
function newRequestId() {
return nextRequestId++
}
const newRequestId = () => nextRequestId++
function bannerLocator(page: Page) {
return page.getByTestId(TestIds.queue.notificationBanner)

View File

@@ -6,11 +6,11 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
const mockOptions = ['d', 'c', 'b', 'a']
async function addRemoteWidgetNode(
const addRemoteWidgetNode = async (
comfyPage: ComfyPage,
nodeName: string,
count: number = 1
) {
) => {
const tab = comfyPage.menu.nodeLibraryTab
await tab.open()
await tab.getFolder('DevTools').click()
@@ -21,24 +21,24 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
}
}
async function getWidgetOptions(
const getWidgetOptions = async (
comfyPage: ComfyPage,
nodeName: string
): Promise<string[] | undefined> {
): Promise<string[] | undefined> => {
return await comfyPage.page.evaluate((name) => {
const node = window.app!.graph!.nodes.find((node) => node.title === name)
return node!.widgets![0].options.values as string[] | undefined
}, nodeName)
}
async function getWidgetValue(comfyPage: ComfyPage, nodeName: string) {
const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => {
return await comfyPage.page.evaluate((name) => {
const node = window.app!.graph!.nodes.find((node) => node.title === name)
return node!.widgets![0].value
}, nodeName)
}
function clickRefreshButton(comfyPage: ComfyPage, nodeName: string) {
const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => {
return comfyPage.page.evaluate((name) => {
const node = window.app!.graph!.nodes.find((node) => node.title === name)
const buttonWidget = node!.widgets!.find((w) => w.name === 'refresh')

View File

@@ -13,21 +13,16 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
function getColorPickerButton(comfyPage: { page: Page }) {
return comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
}
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
function getColorPickerCurrentColor(comfyPage: { page: Page }) {
return comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerCurrentColor
)
}
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
function getColorPickerGroup(comfyPage: { page: Page }) {
return comfyPage.page.getByRole('group').filter({
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
})
}
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -19,8 +18,60 @@ test.describe(
await comfyPage.nextFrame()
})
function openMoreOptions(comfyPage: ComfyPage) {
return openMoreOptionsMenu(comfyPage, 'KSampler')
const openMoreOptions = async (comfyPage: ComfyPage) => {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler to the center of the screen
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()
if (!viewportSize) {
throw new Error(
'Viewport size is null - page may not be properly initialized'
)
}
const centerX = viewportSize.width / 3
const centerY = viewportSize.height / 2
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisible = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisible) {
return
}
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menuOptionsVisibleAfterClick = await comfyPage.page
.getByText('Rename')
.isVisible({ timeout: 2000 })
.catch(() => false)
if (menuOptionsVisibleAfterClick) {
return
}
throw new Error('Could not open More Options menu - popover not showing')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
@@ -41,14 +92,11 @@ test.describe(
)[0]
await openMoreOptions(comfyPage)
// Shape now opens via body-appended popover (FE-570); a hover no
// longer reveals the submenu — match the Color flow and click.
await comfyPage.page.getByText('Shape', { exact: true }).click()
const shapePopover = comfyPage.page
.locator('.p-popover')
.filter({ hasText: 'Default' })
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
await shapePopover.getByText('Box', { exact: true }).click()
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(
comfyPage.page.getByText('Box', { exact: true })
).toBeVisible()
await comfyPage.page.getByText('Box', { exact: true }).click()
await comfyPage.nextFrame()
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)

View File

@@ -1,147 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
sharedWorkflowImportFixture,
sharedWorkflowImportScenario
} from '@e2e/fixtures/sharedWorkflowImportFixture'
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { WorkspaceStore } from '@e2e/types/globals'
const IMPORT_ORDER_TIMEOUT_MS = 5_000
async function expectImportPrecedesPublicInclusiveInputAssetScan(
mocks: SharedWorkflowImportMocks
): Promise<void> {
await expect(async () => {
const events = mocks.getRequestEvents()
const importIndex = events.indexOf('import')
const afterImportIndex = events.indexOf(
'input-assets-including-public-after-import'
)
expect(
events,
'public-inclusive input assets must not be scanned before import'
).not.toContain('input-assets-including-public-before-import')
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
importIndex
)
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage: ComfyPage,
mocks: SharedWorkflowImportMocks
): Promise<void> {
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
await comfyPage.nextFrame()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
.toEqual([])
}
async function openPanelAndExpectNoMissingMedia(
comfyPage: ComfyPage
): Promise<void> {
const page = comfyPage.page
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeHidden()
const panel = new PropertiesPanelHelper(page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
0
)
}
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
// Missing media only surfaces the overlay when the Errors tab is enabled
// (src/stores/executionErrorStore.ts).
test.use({
initialSettings: {
'Comfy.RightSidePanel.ShowErrorsTab': true
}
})
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
sharedWorkflowImportMocks.resetAndStartRecording()
await comfyPage.setup({
clearStorage: false,
url: `/?share=${sharedWorkflowImportScenario.shareId}`
})
})
test('imports shared media before loading workflow so missing media is not surfaced', async ({
comfyPage,
sharedWorkflowImportMocks
}) => {
const { page } = comfyPage
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
await expect(
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
).toBeVisible()
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
await expect
.poll(() =>
page.evaluate(() =>
window.app!.graph.nodes.map((node) => ({
type: node.type,
value: node.widgets?.[0]?.value
}))
)
)
.toEqual([
{
type: 'LoadImage',
value: sharedWorkflowImportScenario.inputFileName
}
])
await expectImportPrecedesPublicInclusiveInputAssetScan(
sharedWorkflowImportMocks
)
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
comfyPage,
sharedWorkflowImportMocks
)
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
share_id: sharedWorkflowImportScenario.shareId
})
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
await openPanelAndExpectNoMissingMedia(comfyPage)
})
})

View File

@@ -1,274 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const historyJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'history-completed',
status: 'completed',
create_time: routeMockJobTimestamp - 60_000,
execution_start_time: routeMockJobTimestamp - 60_000,
execution_end_time: routeMockJobTimestamp - 55_000,
preview_output: {
filename: 'completed-output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createRouteMockJob({
id: 'history-failed',
status: 'failed',
create_time: routeMockJobTimestamp - 120_000,
execution_start_time: routeMockJobTimestamp - 120_000,
execution_end_time: routeMockJobTimestamp - 118_000,
outputs_count: 0,
execution_error: {
node_id: '1',
node_type: 'SaveImage',
exception_message: 'Intentional fixture failure',
exception_type: 'Error',
traceback: [],
current_inputs: {},
current_outputs: {}
}
}),
createRouteMockJob({
id: 'history-cancelled',
status: 'cancelled',
create_time: routeMockJobTimestamp - 180_000,
execution_start_time: routeMockJobTimestamp - 180_000,
execution_end_time: routeMockJobTimestamp - 179_000,
outputs_count: 0
})
]
const activeJobs: RawJobListItem[] = [
createRouteMockJob({
id: 'queue-running',
status: 'in_progress',
create_time: routeMockJobTimestamp - 10_000,
execution_start_time: routeMockJobTimestamp - 9_000,
execution_end_time: null,
outputs_count: 0
}),
createRouteMockJob({
id: 'queue-pending',
status: 'pending',
create_time: routeMockJobTimestamp - 5_000,
execution_start_time: null,
execution_end_time: null,
outputs_count: 0
})
]
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
initialJobsScenario: JobsScenario
mockInitialJobsScenario: void
}>({
initialJobsScenario: [
{ history: historyJobs, queue: activeJobs },
{ option: true }
],
mockInitialJobsScenario: [
async ({ jobsRoutes, initialJobsScenario }, use) => {
await jobsRoutes.mockJobsScenario(initialJobsScenario)
await use()
},
{ auto: true }
]
})
async function openJobHistorySidebar(comfyPage: ComfyPage) {
await comfyPage.page
.getByTestId(TestIds.sidebar.toolbar)
.getByRole('button', { name: 'Job History', exact: true })
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
}
function jobRow(comfyPage: ComfyPage) {
const list = comfyPage.page.getByTestId(TestIds.queue.jobAssetsList)
return (jobId: string) => list.locator(`[data-job-id="${jobId}"]`)
}
function jobHistorySidebar(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.queue.jobHistorySidebar)
}
function clearQueueButton(comfyPage: ComfyPage) {
return jobHistorySidebar(comfyPage).getByRole('button', {
name: 'Clear queue',
exact: true
})
}
async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
await jobHistorySidebar(comfyPage)
.getByLabel(/More options/i)
.click()
await comfyPage.page.getByTestId(TestIds.queue.clearHistoryAction).click()
}
test.describe('Job history sidebar', { tag: '@ui' }, () => {
test.describe('docked overlay action', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
test('opens from the queue overlay docked history action', async ({
comfyPage
}) => {
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
await comfyPage.queuePanel.moreOptionsButton.click()
await comfyPage.page
.getByTestId(TestIds.queue.dockedJobHistoryAction)
.click()
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
})
})
test.describe('expanded history tab', () => {
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
test('shows terminal and active job states', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeEnabled()
})
test('filters completed and failed history jobs', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(row('history-failed')).toBeVisible()
await expect(row('history-cancelled')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
})
test('searches by job id and output filename', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
const searchInput = comfyPage.page.getByPlaceholder('Search...')
await searchInput.fill('history-failed')
await expect(row('history-failed')).toBeVisible()
await expect(row('history-completed')).toBeHidden()
await expect(row('queue-running')).toBeHidden()
await searchInput.fill('completed-output')
await expect(row('history-completed')).toBeVisible()
await expect(row('history-failed')).toBeHidden()
await searchInput.clear()
await expect(row('history-completed')).toBeVisible()
await expect(row('queue-running')).toBeVisible()
})
test('clears pending queue jobs and leaves running/history jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('queue-pending')).toBeVisible()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
await jobsRoutes.mockJobsScenario({
history: historyJobs,
queue: runningOnlyJobs
})
await clearQueueButton(comfyPage).click()
await expect.poll(() => clearQueueRequests.length).toBe(1)
expect(clearQueueRequests).toContainEqual({ clear: true })
await expect(row('queue-pending')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('history-completed')).toBeVisible()
await expect(clearQueueButton(comfyPage)).toBeDisabled()
expect(clearHistoryRequests).toHaveLength(0)
})
test('clears history from the sidebar menu and keeps active jobs', async ({
comfyPage,
jobsRoutes
}) => {
await openJobHistorySidebar(comfyPage)
const row = jobRow(comfyPage)
await expect(row('history-completed')).toBeVisible()
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
const clearQueueRequests = await jobsRoutes.mockClearQueue()
await jobsRoutes.mockJobsScenario({
history: [],
queue: activeJobs
})
await openSidebarClearHistoryDialog(comfyPage)
await expect(
comfyPage.page.getByText('Clear your job queue history?')
).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Clear', exact: true })
.click()
await expect.poll(() => clearHistoryRequests.length).toBe(1)
expect(clearHistoryRequests).toContainEqual({ clear: true })
await expect(row('history-completed')).toBeHidden()
await expect(row('history-failed')).toBeHidden()
await expect(row('queue-running')).toBeVisible()
await expect(row('queue-pending')).toBeVisible()
expect(clearQueueRequests).toHaveLength(0)
})
})
test.describe('without pending queue jobs', () => {
test.use({
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
initialSettings: { 'Comfy.Queue.QPOV2': true }
})
test('disables clear queue', async ({ comfyPage }) => {
await openJobHistorySidebar(comfyPage)
await expect(clearQueueButton(comfyPage)).toBeDisabled()
})
})
})

View File

@@ -114,12 +114,11 @@ test.describe('Sidebar splitter width independence', () => {
await dragGutter(comfyPage, 80)
// Check that saved sizes sum to ~100%
function getSidebarSizes() {
return comfyPage.page.evaluate(() => {
const getSidebarSizes = () =>
comfyPage.page.evaluate(() => {
const raw = localStorage.getItem('unified-sidebar')
return raw ? (JSON.parse(raw) as number[]) : null
})
}
await expect
.poll(async () => {

View File

@@ -25,7 +25,7 @@ const MISSING_NODES_SUBGRAPH_NODE_ID = '2'
* the root graph, then the inner subgraph node that appears inside. Matches
* how a user navigates via the canvas.
*/
async function enterNestedSubgraphs(comfyPage: ComfyPage) {
const enterNestedSubgraphs = async (comfyPage: ComfyPage) => {
const outerNode = await comfyPage.nodeOps.getNodeRefById(
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
)

View File

@@ -689,9 +689,7 @@ test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -737,9 +735,7 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -816,9 +812,7 @@ test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()

View File

@@ -368,16 +368,15 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
]
const SENTINEL_IDS = new Set([-1, -10, -20])
function isSentinelNodeId(id: number | string): id is number {
return typeof id === 'number' && SENTINEL_IDS.has(id)
}
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
function checkEndpoint(
const checkEndpoint = (
label: string,
kind: 'origin_id' | 'target_id',
id: number | string,
g: typeof graph
): string | null {
): string | null => {
if (isSentinelNodeId(id)) return null
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
return `${label}: ${kind} ${id} invalid or not found`

View File

@@ -632,75 +632,3 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
})
})
})
test(
'link interactions',
{ tag: ['@vue-nodes', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const seedSlot = ksampler.getSlot('seed')
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
await test.step('Make second INT typed connection', async () => {
const toPos = await seedIOSlot.getOpenSlotPosition()
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(seedSlot)
}
await expect.poll(isConnected).toBe(true)
})
const stepsSlot = ksampler.getSlot('steps')
await test.step('Node -> I/O hover effect', async () => {
await stepsSlot.hover()
await stepsSlot.click({ trial: true })
await comfyPage.page.mouse.down()
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
const rawClip = await comfyPage.subgraph.getInputBounds()
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
const clip = { ...rawClip, ...absolutePos }
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
clip
})
//cancel link operation
await stepsSlot.hover()
await comfyPage.page.mouse.up()
})
await ksampler.title.hover()
const slotParent = stepsSlot.locator('../..')
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
'opacity',
'0'
)
await test.step('Connect I/O to node with snap', async () => {
function hasSnap() {
return comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
}
expect(await hasSnap()).toBe(false)
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()
//move hover off the slot
await ksampler.title.hover()
})
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
'opacity',
'0'
)
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -14,7 +14,7 @@ test.describe(
)
await comfyPage.vueNodes.waitForNodes()
async function assertInSubgraph(inSubgraph: boolean) {
const assertInSubgraph = async (inSubgraph: boolean) => {
await expect
.poll(() => comfyPage.subgraph.isInSubgraph())
.toBe(inSubgraph)

View File

@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
})
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()

View File

@@ -7,9 +7,9 @@ test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
function createMockSystemStatsRes(
const createMockSystemStatsRes = (
requiredFrontendVersion: string
): SystemStats {
): SystemStats => {
return {
system: {
os: 'posix',

View File

@@ -79,7 +79,7 @@ async function getNodeGroupCenteringErrors(
const nodeRect = nodeElement.getBoundingClientRect()
function getCenteringError(group: GraphGroup): NodeGroupCenteringError {
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
group.pos[0],
group.pos[1]

View File

@@ -1151,14 +1151,12 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
const ksampler = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
if (!node) return null
const ksamplerNode = node
function findIndex(name: string) {
return ksamplerNode.inputs.findIndex(
const findIndex = (name: string) =>
node.inputs.findIndex(
(input) => input.name === name || input.widget?.name === name
)
}
return {
id: ksamplerNode.id,
id: node.id,
denoiseIndex: findIndex('denoise'),
schedulerIndex: findIndex('scheduler')
}

View File

@@ -8,10 +8,10 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
async function getHeaderPos(
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> {
): Promise<{ x: number; y: number; width: number; height: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
@@ -21,30 +21,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
return box
}
async function getLoadCheckpointHeaderPos(comfyPage: ComfyPage) {
return getHeaderPos(comfyPage, 'Load Checkpoint')
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
getHeaderPos(comfyPage, 'Load Checkpoint')
async function expectPosChanged(pos1: Position, pos2: Position) {
const expectPosChanged = async (pos1: Position, pos2: Position) => {
const diffX = Math.abs(pos2.x - pos1.x)
const diffY = Math.abs(pos2.y - pos1.y)
expect(diffX).toBeGreaterThan(0)
expect(diffY).toBeGreaterThan(0)
}
function deltaBetween(before: Position, after: Position) {
return {
x: after.x - before.x,
y: after.y - before.y
}
}
const deltaBetween = (before: Position, after: Position) => ({
x: after.x - before.x,
y: after.y - before.y
})
function expectSameDelta(a: Position, b: Position, tol = 2) {
const expectSameDelta = (a: Position, b: Position, tol = 2) => {
expect(Math.abs(a.x - b.x)).toBeLessThanOrEqual(tol)
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
}
async function dragFromTabButton(comfyPage: ComfyPage, button: Locator) {
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
const box = await button.boundingBox()
if (!box) throw new Error('Tab button has no bounding box')
const start = {
@@ -175,7 +172,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const dx = 120
const dy = 80
async function clickNodeTitleWithMeta(title: string) {
const clickNodeTitleWithMeta = async (title: string) => {
await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')

View File

@@ -15,11 +15,10 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
function getWidth() {
return comfyPage.page.evaluate(
const getWidth = () =>
comfyPage.page.evaluate(
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
)
}
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')

View File

@@ -5,15 +5,11 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
function getFirstClipNode(comfyPage: ComfyPage) {
return comfyPage.vueNodes
.getNodeByTitle('CLIP Text Encode (Prompt)')
.first()
}
const getFirstClipNode = (comfyPage: ComfyPage) =>
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
function getFirstMultilineStringWidget(comfyPage: ComfyPage) {
return getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
}
const getFirstMultilineStringWidget = (comfyPage: ComfyPage) =>
getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
test('should allow entering text', async ({ comfyPage }) => {
const textarea = getFirstMultilineStringWidget(comfyPage)

Some files were not shown because too many files have changed in this diff Show More