Compare commits

..

6 Commits

Author SHA1 Message Date
Alexander Brown
efdbcb8afa Merge branch 'main' into glary/oxlint-func-style 2026-05-20 11:47:23 -07:00
Glary-Bot
a95b9ab3fb fix: convert two arrow expressions introduced after main merge
Two new const-arrow expressions landed via the merged subgraph IO PR
in browser_tests/tests/subgraph/subgraphSlots.spec.ts; convert them
to function declarations to satisfy func-style.
2026-05-20 17:55:13 +00:00
Alexander Brown
96ba23e2f3 Merge branch 'main' into glary/oxlint-func-style 2026-05-20 10:38:09 -07:00
Glary-Bot
d6291de715 fix: preserve type narrowing for nested fn in linkInteraction spec
Codemod converted const-arrow findIndex into a function declaration,
which loses the outer 'if (!node) return null' narrowing of node when
the inner function captures it. Capture into a typed local first.
2026-05-20 17:15:12 +00:00
Glary-Bot
df578b198b fix: drop unused GetStaticPaths imports from astro pages
Codemod converted typed const-arrow getStaticPaths into untyped function
declarations, leaving the GetStaticPaths import unused in 7 astro pages.
2026-05-20 05:58:13 +00:00
Glary-Bot
3e56bc925f lint: enable oxlint func-style rule and convert function expressions
Enables eslint/func-style in oxlint with declaration mode to enforce
function declarations over function expressions and arrow expressions
assigned to variables. Vendored litegraph is excluded via override.

Converts existing function expressions and variable-initialized arrow
functions to function declarations across src/, browser_tests/, apps/,
packages/, and scripts/. Adjusts a handful of let-reassignable callback
placeholders, narrowed variable patterns, and typed widget constructors
to keep type safety intact.

Pre-existing type-aware oxlint errors (no-console, no-floating-promises,
no-explicit-any) are unchanged from main.
2026-05-20 05:44:45 +00:00
777 changed files with 6226 additions and 18727 deletions

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available globally"
echo "TypeScript and Vue compiler already available locally"
fi
- name: Run Claude Documentation Review

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

View File

@@ -28,6 +28,7 @@
],
"rules": {
"no-async-promise-executor": "off",
"func-style": ["error", "declaration"],
"no-console": [
"error",
{
@@ -124,6 +125,12 @@
"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 const withTheme = (Story: StoryFn, context: StoryContext) => {
export function 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 const withTheme = (Story: StoryFn, context: StoryContext) => {
export function 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')
})
const handleCopy = async () => {
async function handleCopy() {
const existingSelection = terminal.getSelection()
const shouldSelectAll = !existingSelection
if (shouldSelectAll) terminal.selectAll()
@@ -76,7 +76,7 @@ const handleCopy = async () => {
}
}
const showContextMenu = (event: MouseEvent) => {
function showContextMenu(event: MouseEvent) {
event.preventDefault()
electronAPI()?.showContextMenu({ type: 'text' })
}

View File

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

View File

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

View File

@@ -92,7 +92,7 @@ const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async (sourcePath: string | undefined) => {
async function validateSource(sourcePath: string | undefined) {
if (!sourcePath) {
pathError.value = ''
return
@@ -109,7 +109,7 @@ const validateSource = async (sourcePath: string | undefined) => {
}
}
const browsePath = async () => {
async function browsePath() {
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)
const toggle = (event: Event) => {
function toggle(event: Event) {
infoPopover.value?.toggle(event)
}
</script>

View File

@@ -67,7 +67,7 @@ defineProps<{
filter: MaintenanceFilter
}>()
const executeTask = async (task: MaintenanceTask) => {
async function executeTask(task: MaintenanceTask) {
let message: string | undefined
try {
@@ -87,7 +87,7 @@ const executeTask = async (task: MaintenanceTask) => {
}
// Commands
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
async function confirmButton(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
const terminalCreated = (
function 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 @@ const terminalCreated = (
terminal.options.disableStdin = true
}
const terminalUnmounted = () => {
function terminalUnmounted() {
xterm = null
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,11 +47,11 @@ import { useRouter } from 'vue-router'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const openGitDownloads = () => {
function openGitDownloads() {
window.open('https://git-scm.com/downloads/', '_blank')
}
const skipGit = async () => {
async function skipGit() {
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
const createMockRouter = () =>
createRouter({
function createMockRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
@@ -23,6 +23,7 @@ const 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)
const handleStepChange = (value: string | number) => {
function handleStepChange(value: string | number) {
setHighestStep(value)
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -98,7 +98,7 @@ const handleStepChange = (value: string | number) => {
})
}
const setHighestStep = (value: string | number) => {
function 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
const goToNextStep = () => {
function goToNextStep() {
const nextStep = (parseInt(currentStep.value) + 1).toString()
currentStep.value = nextStep
setHighestStep(nextStep)
@@ -132,7 +132,7 @@ const goToNextStep = () => {
})
}
const goToPreviousStep = () => {
function goToPreviousStep() {
const prevStep = (parseInt(currentStep.value) - 1).toString()
currentStep.value = prevStep
electronAPI().Events.trackEvent('install_stepper_change', {
@@ -142,7 +142,7 @@ const goToPreviousStep = () => {
const electron = electronAPI()
const router = useRouter()
const install = async () => {
async function install() {
if (!device.value) return
const options: InstallOptions = {

View File

@@ -35,12 +35,14 @@ const validationState: ValidationState = {
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
function createMockElectronAPI() {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
function getValidationUpdate() {
return {
...validationState
}
}
return {
getPlatform: () => 'darwin',
@@ -76,7 +78,7 @@ const createMockElectronAPI = () => {
}
}
const ensureElectronAPI = () => {
function 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. */
const completeValidation = async () => {
async function completeValidation() {
const isValid = await electron.Validation.complete()
if (!isValid) {
toast.add({
@@ -194,7 +194,7 @@ const completeValidation = async () => {
}
}
const toggleConsoleDrawer = () => {
function toggleConsoleDrawer() {
terminalVisible.value = !terminalVisible.value
}

View File

@@ -67,7 +67,9 @@ const electron = electronAPI()
const basePath = ref<string | null>(null)
const sep = ref<'\\' | '/'>('/')
const restartApp = (message?: string) => electron.restartApp(message)
function restartApp(message?: string) {
return 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)
const updateConsent = async () => {
async function updateConsent() {
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'
const openDocs = () => {
function openDocs() {
window.open(
'https://github.com/Comfy-Org/desktop#currently-supported-platforms',
'_blank'
)
}
const reportIssue = () => {
function reportIssue() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const router = useRouter()
const continueToInstall = async () => {
async function continueToInstall() {
await router.push('/install')
}
</script>

View File

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

View File

@@ -3,8 +3,9 @@ import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
function escapeRegExp(value: string): string {
return 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)}`
)
}
const setAllTimes = (time: number) => {
function setAllTimes(time: number) {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
@@ -119,7 +119,9 @@ async function measureMarqueeLoopGeometry(
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
function readX() {
return tracks.map((track) => track.getBoundingClientRect().x)
}
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(

View File

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

View File

@@ -58,7 +58,7 @@ onMounted(() => {
})
raw.sort((a, b) => {
const norm = (v: number) => {
function 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)
const wOf = (elapsed: number) => {
function 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
const createAnimations = () => {
function 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
) {
const clampedTarget = (i: number) => {
function clampedTarget(i: number) {
const center = buttonCenters[i] ?? 0
return Math.max(-(contentH - vpH), Math.min(0, vpH / 2 - center))
}

View File

@@ -1,5 +1,5 @@
{
"fetchedAt": "2026-05-22T00:07:48.353Z",
"fetchedAt": "2026-05-12T16:10:34.114Z",
"departments": [
{
"name": "DESIGN",
@@ -36,14 +36,14 @@
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
@@ -71,14 +71,14 @@
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
@@ -105,21 +105,21 @@
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "Remote",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
@@ -135,20 +135,6 @@
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
}
]
},

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,5 +1,4 @@
---
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'
@@ -7,7 +6,7 @@ import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,5 +1,4 @@
---
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'
@@ -8,7 +7,7 @@ import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

@@ -1,11 +1,10 @@
---
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 const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return models.map((model) => ({
params: { slug: model.slug }
}))

View File

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

View File

@@ -1,5 +1,4 @@
---
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'
@@ -7,7 +6,7 @@ import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return customerStories.map((story) => ({
params: { slug: story.slug }
}))

View File

@@ -1,5 +1,4 @@
---
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'
@@ -8,7 +7,7 @@ import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
export function getStaticPaths() {
return demos.map((demo) => ({
params: { slug: demo.slug }
}))

View File

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

View File

@@ -2,8 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks,
fetchRegistryPacksWithNodes
fetchRegistryPacks
} from './cloudNodes.registry'
function jsonResponse(
@@ -143,315 +142,3 @@ describe('fetchRegistryPacks', () => {
expect(result.size).toBe(0)
})
})
describe('fetchRegistryPacksWithNodes', () => {
it('fetches pack metadata and comfy nodes for each pack', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
// Pack metadata request
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
}
]
})
}
// Comfy nodes request
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(1)
const packData = result.get('comfyui-impact-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
expect(packData?.nodes).toHaveLength(2)
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
})
it('handles pagination for comfy nodes', async () => {
let comfyNodesCallCount = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'big-pack',
name: 'Big Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesCallCount++
const page = Number(url.searchParams.get('page') ?? '1')
if (page === 1) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'Node1', category: 'cat1' },
{ comfy_node_name: 'Node2', category: 'cat1' }
],
totalNumberOfPages: 2
})
} else {
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
totalNumberOfPages: 2
})
}
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesCallCount).toBe(2)
const packData = result.get('big-pack')
expect(packData?.nodes).toHaveLength(3)
})
it('returns null for packs without latest_version', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'no-version-pack',
name: 'No Version Pack',
latest_version: null
}
]
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.get('no-version-pack')).toBeNull()
})
it('returns empty nodes array when comfy-nodes request fails', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'failing-pack',
name: 'Failing Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return new Response('Server error', { status: 500 })
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('failing-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('Failing Pack')
expect(packData?.nodes).toHaveLength(0)
})
it('handles null comfy_nodes in response', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'null-nodes-pack',
name: 'Null Nodes Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: null,
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('null-nodes-pack')
expect(packData?.nodes).toHaveLength(0)
})
it('fetches nodes for multiple packs in parallel', async () => {
const packIds = ['pack-a', 'pack-b', 'pack-c']
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
const requestedIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: requestedIds.map((id) => ({
id,
name: id.toUpperCase(),
latest_version: { version: '1.0.0' }
}))
})
}
if (url.pathname.includes('/comfy-nodes')) {
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: `${packId}-node`, category: 'test' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(3)
for (const packId of packIds) {
const packData = result.get(packId)
expect(packData).not.toBeNull()
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
}
})
it('retries comfy-nodes fetch once on failure', async () => {
let comfyNodesAttempts = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'retry-pack',
name: 'Retry Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesAttempts++
if (comfyNodesAttempts === 1) {
return new Response('Server error', { status: 500 })
}
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesAttempts).toBe(2)
const packData = result.get('retry-pack')
expect(packData?.nodes).toHaveLength(1)
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
})
it('normalizes null boolean fields in comfy nodes', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'bool-pack',
name: 'Bool Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{
comfy_node_name: 'TestNode',
category: 'test',
deprecated: null,
experimental: null
}
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('bool-pack')
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
expect(packData?.nodes[0]?.experimental).toBeUndefined()
})
})

View File

@@ -5,10 +5,8 @@ import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
const COMFY_NODES_PAGE_SIZE = 500
export type RegistryPack = components['schemas']['Node']
export type RegistryComfyNode = components['schemas']['ComfyNode']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
@@ -60,29 +58,6 @@ const RegistryListResponseSchema = z
})
.passthrough()
const RegistryComfyNodeSchema = z
.object({
comfy_node_name: optionalString,
category: optionalString,
description: optionalString,
deprecated: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
experimental: z
.boolean()
.nullish()
.transform((v) => v ?? undefined)
})
.passthrough()
const RegistryComfyNodesResponseSchema = z
.object({
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
totalNumberOfPages: z.number().nullish()
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
@@ -147,142 +122,6 @@ export async function fetchRegistryPacks(
return resolved
}
export interface RegistryPackWithNodes {
pack: RegistryPack
nodes: RegistryComfyNode[]
}
export async function fetchRegistryPacksWithNodes(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPackWithNodes | null>> {
const packs = await fetchRegistryPacks(packIds, options)
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const entries = await Promise.all(
[...packs.entries()].map(
async ([packId, pack]): Promise<
[string, RegistryPackWithNodes | null]
> => {
if (!pack?.latest_version?.version) {
return [packId, null]
}
const nodes = await fetchComfyNodesForPack(
fetchImpl,
baseUrl,
packId,
pack.latest_version.version,
timeoutMs
)
return [packId, { pack, nodes }]
}
)
)
return new Map(entries)
}
async function fetchComfyNodesForPack(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
timeoutMs: number
): Promise<RegistryComfyNode[]> {
const allNodes: RegistryComfyNode[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const result = await fetchComfyNodesPageWithRetry(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (!result) break
allNodes.push(...result.nodes)
totalPages = result.totalPages
page++
}
return allNodes
}
async function fetchComfyNodesPageWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const firstAttempt = await fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (firstAttempt) return firstAttempt
// Retry once on failure
return fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
}
async function fetchComfyNodesPage(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) return null
const rawBody: unknown = await res.json()
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
if (!parsed.success) return null
return {
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
totalPages: parsed.data.totalNumberOfPages ?? 1
}
} catch {
return null
} finally {
clearTimeout(timer)
}
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,

View File

@@ -8,16 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
import type { RegistryPackWithNodes } from './cloudNodes.registry'
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
)
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
fetchRegistryPacks: fetchRegistryPacksMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
@@ -94,8 +90,8 @@ describe('fetchCloudNodesForBuild', () => {
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksWithNodesMock.mockReset()
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
@@ -106,21 +102,14 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksWithNodesMock.mockResolvedValue(
new Map<string, RegistryPackWithNodes | null>([
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'comfyui-impact-pack',
{
pack: {
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '1.0.0' }
},
nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
]
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
}
]
])
@@ -140,10 +129,6 @@ describe('fetchCloudNodesForBuild', () => {
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
// Nodes should come from registry, not object_info
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
@@ -312,7 +297,7 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
@@ -320,8 +305,5 @@ describe('fetchCloudNodesForBuild', () => {
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
// Falls back to object_info nodes when registry fails
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
})
})

View File

@@ -6,15 +6,12 @@ import {
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type {
RegistryComfyNode,
RegistryPackWithNodes
} from './cloudNodes.registry'
import type { RegistryPack } from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
@@ -238,28 +235,26 @@ async function parseCloudNodes(
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
// Use object_info to determine which packs are cloud-supported
const grouped = groupNodesByPack(sanitizedDefs)
const packIds = grouped.map((pack) => pack.id)
// Fetch full pack metadata and node list from registry
let registryMap = new Map<string, RegistryPackWithNodes | null>()
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: options.fetchImpl
})
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
const packs = grouped
.map((pack) => {
const registryData = registryMap.get(pack.id)
// Use registry nodes if available, otherwise fall back to object_info nodes
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
})
.filter((pack) => pack.nodes.length > 0)
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
@@ -279,7 +274,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
function toDomainPack(
packId: string,
fallbackDisplayName: string,
objectInfoNodes: Array<{
nodes: Array<{
className: string
def: {
display_name: string
@@ -289,18 +284,8 @@ function toDomainPack(
experimental?: boolean
}
}>,
registryData: RegistryPackWithNodes | null | undefined
registryPack: RegistryPack | null | undefined
): Pack {
const registryPack = registryData?.pack
// Prefer registry nodes if available, fall back to object_info nodes
const nodes =
registryData?.nodes && registryData.nodes.length > 0
? registryData.nodes
.map((node) => toDomainNodeFromRegistry(node))
.filter((n): n is PackNode => n !== null)
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
return {
id: packId,
registryId: registryPack?.id,
@@ -323,20 +308,9 @@ function toDomainPack(
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
if (!node.comfy_node_name) return null
return {
name: node.comfy_node_name,
displayName: node.comfy_node_name,
category: node.category || '',
description: node.description || undefined,
deprecated: node.deprecated,
experimental: node.experimental
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}

View File

@@ -1,10 +1,8 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { Position } from '@e2e/fixtures/types'
const { searchBoxV2 } = TestIds
@@ -86,12 +84,11 @@ export class ComfyNodeSearchBoxV2 {
await this.input.waitFor({ state: 'visible' })
}
async openByDoubleClickCanvas(position?: Position) {
const { x, y } = position ?? { x: 200, y: 200 }
async openByDoubleClickCanvas(): Promise<void> {
// Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay
// does not intercept; coords target a viewport spot that is on the canvas
// and clear of both the side toolbar and any default-graph nodes.
await this.comfyPage.page.mouse.dblclick(x, y, { delay: 5 })
await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
}
async ensureV2Search(): Promise<void> {
@@ -112,14 +109,4 @@ export class ComfyNodeSearchBoxV2 {
'search box'
)
}
async addNode(query: string, options: { position?: Position } = {}) {
const position = options.position ?? { x: 200, y: 200 }
await this.openByDoubleClickCanvas(position)
await this.input.fill(query)
await expect(this.results.first()).toContainText(query)
await this.comfyPage.page.keyboard.press('Enter')
await expect(this.dialog).toBeHidden()
await this.comfyPage.page.mouse.click(position.x, position.y)
}
}

View File

@@ -2,24 +2,10 @@ import type { Locator } from '@playwright/test'
export class WidgetSelectDropdownFixture {
public readonly selection: Locator
public readonly trigger: Locator
constructor(public readonly root: Locator) {
this.trigger = root.locator('button:has(> span)').first()
this.selection = root.locator('button span span')
}
async open(): Promise<void> {
await this.trigger.click()
}
async searchAndSelectTop(popover: Locator, query: string): Promise<void> {
await this.open()
const searchInput = popover.getByRole('textbox')
await searchInput.fill(query)
await searchInput.press('Enter')
}
async selectedItem(): Promise<string> {
return await this.selection.innerText()
}

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
/**
* Helper for interacting with widgets rendered in app mode (linear view).
@@ -25,11 +24,6 @@ export class AppModeWidgetHelper {
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
getSelectDropdown(key: string): WidgetSelectDropdownFixture {
return new WidgetSelectDropdownFixture(this.getWidgetItem(key))
}
/** Fill a textarea widget (e.g. CLIP Text Encode prompt). */
async fillTextarea(key: string, value: string) {
const widget = this.getWidgetItem(key)

View File

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

View File

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

View File

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

View File

@@ -618,7 +618,7 @@ export class SubgraphHelper {
]
): { warnings: string[]; dispose: () => void } {
const warnings: string[] = []
const handler = (msg: ConsoleMessage) => {
function 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> {
const thumbnailHandler = async (route: Route) => {
async function thumbnailHandler(route: Route) {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',

View File

@@ -8,7 +8,6 @@ import {
} from '@comfyorg/ingest-types/zod'
import type {
JobDetail,
JobStatus,
RawJobListItem,
zJobsListResponse
@@ -183,24 +182,6 @@ export class JobsRouteMocker {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockDeleteHistory(): Promise<HistoryManageRequest[]> {
return await this.mockPostManageRoute('history', zHistoryManageRequest, {})
}
async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> {
await this.page.route(
(url) => url.pathname.endsWith(`/api/jobs/${encodeURIComponent(jobId)}`),
async (requestRoute) => {
if (requestRoute.request().method().toUpperCase() !== 'GET') {
await requestRoute.fallback()
return
}
await requestRoute.fulfill({ json: detail })
}
)
}
private async mockPostManageRoute<TRequest>(
type: 'queue' | 'history',
requestSchema: z.ZodType<TRequest>,

View File

@@ -130,10 +130,12 @@ export const sharedWorkflowImportFixture = base.extend<{
async function mockSharedWorkflowImportFlow(
page: Page
): Promise<SharedWorkflowImportMocks> {
function noopResolveResponse() {}
let isRecording = false
let importEndpointCalled = false
let importBody: ImportPublishedAssetsRequest | undefined
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void = () => {}
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void =
noopResolveResponse
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
(resolve) => {
resolvePublicInclusiveInputAssetResponseAfterImport = resolve

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 const getMiddlePoint = (pos1: Position, pos2: Position) => {
export function getMiddlePoint(pos1: Position, pos2: Position) {
return {
x: (pos1.x + pos2.x) / 2,
y: (pos1.y + pos2.y) / 2

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
const triggerChange = async (value: number) => {
async function triggerChange(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
const triggerStatus = (queueSize: number) => {
function 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
const getQueuedWidth = async (resp: Promise<Response>) => {
async function getQueuedWidth(resp: Promise<Response>) {
const obj = await (await resp).json()
return obj['__request']['prompt']['5']['inputs']['width']
}

View File

@@ -2,10 +2,16 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
const { centerPanel } = comfyPage.appMode
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel, 'Enter app mode').toBeVisible()
@@ -19,12 +25,15 @@ test.describe('App mode usage', () => {
//prep a load image
await test.step('Add a load image node', async () => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
await expect(loadImage).toBeVisible()
})
const imageInput = comfyPage.appMode.widgets.getSelectDropdown('10:image')
const imageInput = new WidgetSelectDropdownFixture(
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
)
await test.step('Enter app mode with image input', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
@@ -98,45 +107,6 @@ test.describe('App mode usage', () => {
//verify values are consistent with litegraph
})
test('FormDropdown search Enter selects the top filtered item', async ({
comfyPage
}) => {
await comfyPage.appMode.enableLinearMode()
const loadImageNode = await comfyPage.nodeOps.addNode('LoadImage')
await comfyPage.nextFrame()
const fileComboWidget = await loadImageNode.getWidget(0)
const targetImage = String(await fileComboWidget.getValue())
const initialImage = 'not-selected.png'
await comfyPage.page.evaluate(
([nodeId, value]) => {
const node = window.app!.graph!.getNodeById(nodeId)
const widget = node?.widgets?.[0]
if (!widget) throw new Error(`Image widget not found: ${nodeId}`)
widget.value = value
},
[loadImageNode.id, initialImage] as const
)
await expect.poll(() => fileComboWidget.getValue()).toBe(initialImage)
await comfyPage.appMode.enterAppModeWithInputs([
[String(loadImageNode.id), 'image']
])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageNode.id}:image`
)
const popover = comfyPage.appMode.imagePickerPopover
await expect(imageInput.root).toBeVisible()
await imageInput.searchAndSelectTop(popover, targetImage)
await expect(popover).toBeHidden()
await expect(imageInput.selection).toHaveText(targetImage)
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -75,28 +75,33 @@ test.describe('App mode builder selection', () => {
})
test('Marks canvas readOnly', async ({ comfyPage }) => {
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas is initially editable'
).toBeVisible()
).toHaveCount(1)
await comfyPage.page.keyboard.press('Escape')
await comfyPage.appMode.enterBuilder()
await comfyPage.appMode.steps.goToInputs()
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Entering builder makes the canvas readonly'
).toBeHidden()
).toHaveCount(0)
await comfyPage.page.keyboard.press('Space')
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas remains readonly after pressing space'
).toBeHidden()
).toHaveCount(0)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
@@ -107,10 +112,10 @@ test.describe('App mode builder selection', () => {
).toBeHidden()
await comfyPage.page.keyboard.press('Escape')
await comfyPage.searchBoxV2.openByDoubleClickCanvas()
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
await expect(
comfyPage.searchBoxV2.input,
comfyPage.searchBox.input,
'Canvas is no longer readonly after exiting'
).toBeVisible()
).toHaveCount(1)
})
})

View File

@@ -131,10 +131,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
const imageInput = comfyPage.appMode.widgets.getSelectDropdown(
`${loadImageId}:image`
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
)
await imageInput.open()
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").

View File

@@ -5,16 +5,18 @@ import {
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Size } from '@e2e/fixtures/types'
const expectedGroupSize = (
function expectedGroupSize(
nodeBounds: Size,
padding: number,
titleHeight: number
): 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
})
): 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
}
}
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('Comfy.SnapToGrid.GridSize', () => {
@@ -24,7 +26,7 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.nodeOps.clearGraph()
})
const createNode = async (comfyPage: ComfyPage) => {
async function createNode(comfyPage: ComfyPage) {
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
x: 0,
y: 0
@@ -79,10 +81,10 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
const groupAroundAllNodesWithPadding = async (
async function groupAroundAllNodesWithPadding(
comfyPage: ComfyPage,
padding: number
): Promise<Size> => {
): Promise<Size> {
await comfyPage.settings.setSetting(
'Comfy.GroupSelectedNodes.Padding',
padding
@@ -126,15 +128,16 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
test.describe('LiteGraph.ContextMenu.Scaling', () => {
const ZOOM_SCALE = 2
const litegraphContextMenu = (comfyPage: ComfyPage) =>
comfyPage.page.locator('.litecontextmenu')
function litegraphContextMenu(comfyPage: ComfyPage) {
return comfyPage.page.locator('.litecontextmenu')
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
})
const openComboMenu = async (comfyPage: ComfyPage) => {
async function openComboMenu(comfyPage: ComfyPage) {
const loadImage = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]

View File

@@ -3,12 +3,14 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
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' })
})
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 MODES = [
{

View File

@@ -7,7 +7,7 @@ import { sleep } from '@e2e/fixtures/utils/timing'
const CLIP_NODE_COUNT = 2
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
async function getClipNodesDragBox(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.
*/
const holdDragAt = async (
async function holdDragAt(
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,8 +383,9 @@ 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.
const getFrameGap = (comfyPage: ComfyPage) =>
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
function getFrameGap(comfyPage: ComfyPage) {
return 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())!
const bypassAndPin = async () => {
async function bypassAndPin() {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
@@ -228,14 +228,14 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await afterChange(comfyPage)
}
const collapse = async () => {
async function collapse() {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
const multipleChanges = async () => {
async function multipleChanges() {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')

View File

@@ -133,10 +133,11 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
await node.click('title')
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
function getMode() {
return 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)
const dirtyGraphAndSave = async () => {
async function dirtyGraphAndSave() {
await incrementButton.click()
await comfyPage.page.keyboard.press('Control+s')
}

View File

@@ -21,13 +21,14 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const getGroupPositions = () =>
comfyPage.page.evaluate(() =>
function getGroupPositions() {
return 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
}) => {
const makeGroup = async (name: string, type1: string, type2: string) => {
async function makeGroup(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
}) => {
const expectSingleNode = async (type: string) => {
async function expectSingleNode(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}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
async function isRegisteredLitegraph(comfyPage: ComfyPage) {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window.LiteGraph!.registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
async function isRegisteredNodeDefStore(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
}
const verifyNodeLoaded = async (
async function verifyNodeLoaded(
comfyPage: ComfyPage,
expectedCount: number
) => {
) {
expect(
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
).toHaveLength(expectedCount)

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[] = []
const onPageError = (err: Error) => {
function onPageError(err: Error) {
pageErrors.push(err)
}
comfyPage.page.on('pageerror', onPageError)

View File

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

View File

@@ -1,5 +1,4 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
@@ -44,45 +43,4 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await expect(comfyPage.page.getByTestId('linear-widgets')).toBeVisible()
await expect(comfyPage.canvas).toBeHidden()
})
test('Spinner persists until workflow loaded', async ({
page,
request
}, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
const username = `playwright-test-${parallelIndex}`
const userId = await comfyPage.setupUser(username)
comfyPage.userIds[parallelIndex] = userId
await page.goto(`${comfyPage.url}/api/users`)
await page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, comfyPage.id)
const splash = page.locator('#splash-loader')
let notifyWorkflowRequested!: () => void
const workflowRequested = new Promise<void>(
(r) => (notifyWorkflowRequested = r)
)
let unblockRequest!: () => void
const requestUnblocked = new Promise<void>((r) => (unblockRequest = r))
await page.route('**/templates/default.json', async (route) => {
notifyWorkflowRequested()
await requestUnblocked
return route.continue()
})
await comfyPage.goto({ url: `${comfyPage.url}/?template=default` })
await workflowRequested
await comfyPage.nextFrame()
await expect(splash).toBeVisible()
unblockRequest()
await expect(splash).toBeHidden()
})
})

View File

@@ -3,14 +3,15 @@ import type { Page } from '@playwright/test'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
const getGizmoConfig = (page: Page) =>
page.evaluate(() => {
function getGizmoConfig(page: Page) {
return 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)
const readBackgroundImage = async () => {
async function readBackgroundImage() {
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)
const readShowGrid = async () => {
async function readShowGrid() {
const properties =
await node.getProperty<Record<string, { showGrid?: boolean }>>(
'properties'

View File

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

View File

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

View File

@@ -243,15 +243,18 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
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(
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(
'aria-expanded',
value
)
}
await switchToDesktop()
await searchBoxV2.open()

View File

@@ -312,7 +312,9 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const getCount = () => searchBoxV2.results.count()
function getCount() {
return 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()
const hasContentAtRow = (yFraction: number) =>
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
function hasContentAtRow(yFraction: number) {
return canvas.evaluate((el: HTMLCanvasElement, y: number) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cy = Math.floor(el.height * y)
@@ -769,6 +769,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
}
return false
}, yFraction)
}
await expect
.poll(() => hasContentAtRow(0.25), {

View File

@@ -5,6 +5,7 @@ import {
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)
@@ -12,7 +13,9 @@ test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
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()

View File

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

View File

@@ -15,7 +15,9 @@ const REQUEST_ID_SECONDARY = 2
const REQUEST_ID_MISMATCH = 999
let nextRequestId = 1000
const newRequestId = () => nextRequestId++
function newRequestId() {
return 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']
const addRemoteWidgetNode = async (
async function addRemoteWidgetNode(
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' }, () => {
}
}
const getWidgetOptions = async (
async function getWidgetOptions(
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)
}
const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => {
async function getWidgetValue(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)
}
const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => {
function 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,16 +13,21 @@ test.beforeEach(async ({ comfyPage }) => {
const BLUE_COLOR = 'rgb(51, 51, 85)'
const RED_COLOR = 'rgb(85, 51, 51)'
const getColorPickerButton = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
function getColorPickerButton(comfyPage: { page: Page }) {
return comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
}
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
function getColorPickerCurrentColor(comfyPage: { page: Page }) {
return comfyPage.page.getByTestId(
TestIds.selectionToolbox.colorPickerCurrentColor
)
}
const getColorPickerGroup = (comfyPage: { page: Page }) =>
comfyPage.page.getByRole('group').filter({
function getColorPickerGroup(comfyPage: { page: Page }) {
return 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

@@ -19,8 +19,9 @@ test.describe(
await comfyPage.nextFrame()
})
const openMoreOptions = (comfyPage: ComfyPage) =>
openMoreOptionsMenu(comfyPage, 'KSampler')
function openMoreOptions(comfyPage: ComfyPage) {
return openMoreOptionsMenu(comfyPage, 'KSampler')
}
test('hides Node Info from More Options menu when the new menu is disabled', async ({
comfyPage

View File

@@ -10,9 +10,6 @@ import type {
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------

View File

@@ -1,278 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createRouteMockJob,
jobsRouteFixture,
routeMockJobTimestamp
} from '@e2e/fixtures/jobsRouteFixture'
import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
interface ViewFile {
body?: Buffer | string
contentType?: string
}
type ViewFilesByName = Readonly<Record<string, ViewFile>>
const transparentPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lwPIRwAAAABJRU5ErkJggg==',
'base64'
)
const alphaJob = createRouteMockJob({
id: 'alpha',
create_time: routeMockJobTimestamp - 1_000,
execution_start_time: routeMockJobTimestamp - 1_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'alpha.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
})
const betaJob = createRouteMockJob({
id: 'beta',
create_time: routeMockJobTimestamp - 2_000,
execution_start_time: routeMockJobTimestamp - 2_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'beta.png',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
}
})
const multiOutputJob = createRouteMockJob({
id: 'multi-output',
create_time: routeMockJobTimestamp - 3_000,
execution_start_time: routeMockJobTimestamp - 3_000,
execution_end_time: routeMockJobTimestamp,
preview_output: {
filename: 'multi-output-a.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 2
})
const multiOutputJobDetail: JobDetail = {
...multiOutputJob,
outputs: {
'3': {
images: [
{
filename: 'multi-output-a.png',
subfolder: '',
type: 'output'
},
{
filename: 'multi-output-b.png',
subfolder: '',
type: 'output'
}
]
}
}
}
const generatedJobs: RawJobListItem[] = [alphaJob, betaJob]
const viewFiles = {
'alpha.png': {},
'beta.png': {},
'imported.png': {},
'multi-output-a.png': {},
'multi-output-b.png': {}
}
async function mockInputFiles(page: Page, files: readonly string[]) {
await page.route('**/internal/files/input**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({ json: [...files] })
})
}
async function mockViewFiles(page: Page, filesByName: ViewFilesByName) {
await page.route('**/api/view**', async (route) => {
if (route.request().method().toUpperCase() !== 'GET') {
await route.fallback()
return
}
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
if (!filename) {
await route.fulfill({
status: 400,
json: { error: 'Missing filename' } satisfies { error: string }
})
return
}
const file = filesByName[filename]
if (!file) {
await route.fulfill({
status: 404,
json: {
error: `Unknown filename: ${filename}`
} satisfies { error: string }
})
return
}
await route.fulfill({
body: file.body ?? transparentPng,
contentType: file.contentType ?? 'image/png'
})
})
}
test.describe('FE-130 assets sidebar route mocks', () => {
test.beforeEach(async ({ jobsRoutes, page }) => {
await jobsRoutes.mockJobsQueue([])
await jobsRoutes.mockJobsHistory(generatedJobs)
await mockInputFiles(page, ['imported.png'])
await mockViewFiles(page, viewFiles)
})
test('renders generated and imported assets with image previews', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
await expect(tab.getAssetCardByName('beta')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'alpha.png' })
).toHaveJSProperty('naturalWidth', 1)
await tab.switchToImported()
await expect(tab.getAssetCardByName('imported')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'imported.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('opens previews for generated and imported images', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await comfyPage.page.getByRole('img', { name: 'alpha.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'alpha.png'
})
).toBeVisible()
await comfyPage.mediaLightbox.closeButton.click()
await expect(comfyPage.mediaLightbox.root).toBeHidden()
await tab.switchToImported()
await comfyPage.page.getByRole('img', { name: 'imported.png' }).dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
await expect(
comfyPage.mediaLightbox.root.getByRole('img', {
name: 'imported.png'
})
).toBeVisible()
})
test('shows footer actions for single and multiple generated selections', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await tab.getAssetCardByName('alpha').click()
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
await comfyPage.page.keyboard.down('Control')
await tab.getAssetCardByName('beta').click()
await comfyPage.page.keyboard.up('Control')
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
await expect(tab.deleteSelectedButton).toBeVisible()
await expect(tab.downloadSelectedButton).toBeVisible()
})
test('loads full generated job outputs from job detail', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await jobsRoutes.mockJobsHistory([multiOutputJob])
await jobsRoutes.mockJobDetail('multi-output', multiOutputJobDetail)
await comfyPage.setup()
await tab.open()
await tab
.getAssetCardByName('multi-output-a')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(tab.getAssetCardByName('multi-output-b')).toBeVisible()
await expect(
comfyPage.page.getByRole('img', { name: 'multi-output-b.png' })
).toHaveJSProperty('naturalWidth', 1)
})
test('deletes a generated output asset through explicit history refresh', async ({
comfyPage,
jobsRoutes
}) => {
const tab = comfyPage.menu.assetsTab
await comfyPage.setup()
await tab.open()
await expect(tab.getAssetCardByName('alpha')).toBeVisible()
const deleteRequests = await jobsRoutes.mockDeleteHistory()
await jobsRoutes.mockJobsHistory([betaJob])
await tab.getAssetCardByName('alpha').click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
await comfyPage.confirmDialog.delete.click()
await expect.poll(() => deleteRequests).toHaveLength(1)
expect(deleteRequests[0]).toEqual({ delete: ['alpha'] })
await expect(tab.getAssetCardByName('alpha')).toHaveCount(0)
await expect(comfyPage.toast.toastSuccesses).toContainText(
'Asset deleted successfully'
)
})
})

View File

@@ -129,26 +129,4 @@ test.describe('Node library sidebar V2', () => {
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
test('Click-to-place from sidebar selects the newly added node', async ({
comfyPage
}) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await comfyPage.nodeOps.clearGraph()
await tab.expandFolder('sampling')
const canvasBox = (await comfyPage.canvas.boundingBox())!
const target = {
x: canvasBox.width / 2,
y: canvasBox.height / 2
}
await tab.getNode('KSampler (Advanced)').click()
await comfyPage.canvas.click({ position: target })
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
})
})

View File

@@ -114,11 +114,12 @@ test.describe('Sidebar splitter width independence', () => {
await dragGutter(comfyPage, 80)
// Check that saved sizes sum to ~100%
const getSidebarSizes = () =>
comfyPage.page.evaluate(() => {
function getSidebarSizes() {
return 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.
*/
const enterNestedSubgraphs = async (comfyPage: ComfyPage) => {
async function enterNestedSubgraphs(comfyPage: ComfyPage) {
const outerNode = await comfyPage.nodeOps.getNodeRefById(
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
)

View File

@@ -5,6 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
})
test.describe('Subgraph Clipboard Operations', () => {
@@ -54,7 +58,8 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.searchBoxV2.addNode('Note')
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
await comfyPage.nextFrame()
const initialCount = await comfyPage.subgraph.getNodeCount()

View File

@@ -689,7 +689,9 @@ 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 })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -735,7 +737,9 @@ 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 })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -745,19 +749,20 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
})
await test.step('Convert both nodes to subgraph', async () => {
@@ -811,7 +816,9 @@ 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 })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(fromSlot)
}
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()

View File

@@ -368,15 +368,16 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
]
const SENTINEL_IDS = new Set([-1, -10, -20])
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
function isSentinelNodeId(id: number | string): id is number {
return typeof id === 'number' && SENTINEL_IDS.has(id)
}
const checkEndpoint = (
function 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

@@ -647,7 +647,9 @@ test(
await test.step('Make second INT typed connection', async () => {
const toPos = await seedIOSlot.getOpenSlotPosition()
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
function isConnected() {
return comfyPage.vueNodes.isSlotConnected(seedSlot)
}
await expect.poll(isConnected).toBe(true)
})
@@ -680,8 +682,9 @@ test(
)
await test.step('Connect I/O to node with snap', async () => {
const hasSnap = () =>
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
function hasSnap() {
return comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
}
expect(await hasSnap()).toBe(false)
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()

View File

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

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