mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
Compare commits
6 Commits
v1.45.13
...
glary/oxli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efdbcb8afa | ||
|
|
a95b9ab3fb | ||
|
|
96ba23e2f3 | ||
|
|
d6291de715 | ||
|
|
df578b198b | ||
|
|
3e56bc925f |
2
.github/workflows/pr-claude-review.yaml
vendored
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/weekly-docs-check.yaml
vendored
6
.github/workflows/weekly-docs-check.yaml
vendored
@@ -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
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,7 +52,7 @@ const electron = electronAPI()
|
||||
|
||||
const terminalVisible = ref(false)
|
||||
|
||||
const toggleConsoleDrawer = () => {
|
||||
function toggleConsoleDrawer() {
|
||||
terminalVisible.value = !terminalVisible.value
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user