mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
5 Commits
glary/oxli
...
glary/mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87b5eba54 | ||
|
|
3b75d9c1e1 | ||
|
|
954dbd1f4a | ||
|
|
ae7e16c7fc | ||
|
|
8d2b1d16e6 |
@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
],
|
||||
"rules": {
|
||||
"no-async-promise-executor": "off",
|
||||
"func-style": ["error", "declaration"],
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
@@ -125,12 +124,6 @@
|
||||
"no-console": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/lib/litegraph/**"],
|
||||
"rules": {
|
||||
"func-style": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["browser_tests/**/*.ts"],
|
||||
"jsPlugins": ["eslint-plugin-playwright"],
|
||||
|
||||
@@ -47,7 +47,7 @@ setup((app) => {
|
||||
})
|
||||
|
||||
// Theme and dialog decorator
|
||||
export function withTheme(Story: StoryFn, context: StoryContext) {
|
||||
export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
const theme = context.globals.theme || 'light'
|
||||
|
||||
// Apply theme class to document root
|
||||
|
||||
@@ -40,7 +40,7 @@ setup((app) => {
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
export function withTheme(Story: StoryFn, context: StoryContext) {
|
||||
export const withTheme = (Story: StoryFn, context: StoryContext) => {
|
||||
const theme = context.globals.theme || 'light'
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark-theme')
|
||||
|
||||
@@ -58,7 +58,7 @@ const tooltipText = computed(() => {
|
||||
: t('serverStart.copyAllTooltip')
|
||||
})
|
||||
|
||||
async function handleCopy() {
|
||||
const handleCopy = async () => {
|
||||
const existingSelection = terminal.getSelection()
|
||||
const shouldSelectAll = !existingSelection
|
||||
if (shouldSelectAll) terminal.selectAll()
|
||||
@@ -76,7 +76,7 @@ async function handleCopy() {
|
||||
}
|
||||
}
|
||||
|
||||
function showContextMenu(event: MouseEvent) {
|
||||
const showContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
electronAPI()?.showContextMenu({ type: 'text' })
|
||||
}
|
||||
|
||||
@@ -44,9 +44,8 @@ const emit = defineEmits<{
|
||||
|
||||
const validationState = ref<ValidationState>(ValidationState.IDLE)
|
||||
|
||||
function cleanInput(value: string): string {
|
||||
return value ? value.replace(/\s+/g, '') : ''
|
||||
}
|
||||
const cleanInput = (value: string): string =>
|
||||
value ? value.replace(/\s+/g, '') : ''
|
||||
|
||||
// Add internal value state
|
||||
const internalValue = ref(cleanInput(props.modelValue))
|
||||
@@ -69,14 +68,14 @@ onMounted(async () => {
|
||||
await validateUrl(props.modelValue)
|
||||
})
|
||||
|
||||
function handleInput(value: string | undefined) {
|
||||
const handleInput = (value: string | undefined) => {
|
||||
// Update internal value without emitting
|
||||
internalValue.value = cleanInput(value ?? '')
|
||||
// Reset validation state when user types
|
||||
validationState.value = ValidationState.IDLE
|
||||
}
|
||||
|
||||
async function handleBlur() {
|
||||
const handleBlur = async () => {
|
||||
const input = cleanInput(internalValue.value)
|
||||
|
||||
let normalizedUrl = input
|
||||
@@ -92,7 +91,7 @@ async function handleBlur() {
|
||||
}
|
||||
|
||||
// Default validation implementation
|
||||
async function defaultValidateUrl(url: string): Promise<boolean> {
|
||||
const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
if (!isValidUrl(url)) return false
|
||||
try {
|
||||
return await checkUrlReachable(url)
|
||||
@@ -101,7 +100,7 @@ async function defaultValidateUrl(url: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUrl(value: string) {
|
||||
const validateUrl = async (value: string) => {
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -127,7 +127,7 @@ const showDialog = ref(false)
|
||||
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
|
||||
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
|
||||
|
||||
function showMetricsInfo() {
|
||||
const showMetricsInfo = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -182,12 +182,10 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
function useFallbackMirror(mirror: UVMirror) {
|
||||
return {
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
}
|
||||
}
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
@@ -214,7 +212,7 @@ onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
async function validatePath(path: string | undefined) {
|
||||
const validatePath = async (path: string | undefined) => {
|
||||
try {
|
||||
pathError.value = ''
|
||||
pathExists.value = false
|
||||
@@ -248,7 +246,7 @@ async function validatePath(path: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
async function browsePath() {
|
||||
const browsePath = async () => {
|
||||
try {
|
||||
const result = await electron.showDirectoryPicker()
|
||||
if (result) {
|
||||
@@ -260,7 +258,7 @@ async function browsePath() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onFocus() {
|
||||
const onFocus = async () => {
|
||||
if (!inputTouched.value) {
|
||||
inputTouched.value = true
|
||||
return
|
||||
|
||||
@@ -92,7 +92,7 @@ const isValidSource = computed(
|
||||
() => sourcePath.value !== '' && pathError.value === ''
|
||||
)
|
||||
|
||||
async function validateSource(sourcePath: string | undefined) {
|
||||
const validateSource = async (sourcePath: string | undefined) => {
|
||||
if (!sourcePath) {
|
||||
pathError.value = ''
|
||||
return
|
||||
@@ -109,7 +109,7 @@ async function validateSource(sourcePath: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
async function browsePath() {
|
||||
const browsePath = async () => {
|
||||
try {
|
||||
const result = await electron.showDirectoryPicker()
|
||||
if (result) {
|
||||
|
||||
@@ -82,7 +82,7 @@ const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
|
||||
// Popover
|
||||
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
function toggle(event: Event) {
|
||||
const toggle = (event: Event) => {
|
||||
infoPopover.value?.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,7 +67,7 @@ defineProps<{
|
||||
filter: MaintenanceFilter
|
||||
}>()
|
||||
|
||||
async function executeTask(task: MaintenanceTask) {
|
||||
const executeTask = async (task: MaintenanceTask) => {
|
||||
let message: string | undefined
|
||||
|
||||
try {
|
||||
@@ -87,7 +87,7 @@ async function executeTask(task: MaintenanceTask) {
|
||||
}
|
||||
|
||||
// Commands
|
||||
async function confirmButton(event: MouseEvent, task: MaintenanceTask) {
|
||||
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
|
||||
if (!task.requireConfirm) {
|
||||
await executeTask(task)
|
||||
return
|
||||
|
||||
@@ -34,10 +34,10 @@ const buffer = useTerminalBuffer()
|
||||
let xterm: Terminal | null = null
|
||||
|
||||
// Created and destroyed with the Drawer - contents copied from hidden buffer
|
||||
function terminalCreated(
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
) => {
|
||||
xterm = terminal
|
||||
useAutoSize({ root, autoRows: true, autoCols: true })
|
||||
terminal.write(props.defaultMessage)
|
||||
@@ -49,7 +49,7 @@ function terminalCreated(
|
||||
terminal.options.disableStdin = true
|
||||
}
|
||||
|
||||
function terminalUnmounted() {
|
||||
const terminalUnmounted = () => {
|
||||
xterm = null
|
||||
}
|
||||
|
||||
|
||||
@@ -55,14 +55,14 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
minRows?: number
|
||||
onResize?: () => void
|
||||
}) {
|
||||
function ensureValidRows(rows: number | undefined): number {
|
||||
const ensureValidRows = (rows: number | undefined): number => {
|
||||
if (rows == null || isNaN(rows)) {
|
||||
return (root.value?.clientHeight ?? 80) / 20
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function ensureValidCols(cols: number | undefined): number {
|
||||
const ensureValidCols = (cols: number | undefined): number => {
|
||||
if (cols == null || isNaN(cols)) {
|
||||
// Sometimes this is NaN if so, estimate.
|
||||
return (root.value?.clientWidth ?? 80) / 8
|
||||
@@ -70,7 +70,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
return cols
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const resize = () => {
|
||||
const dims = fitAddon.proposeDimensions()
|
||||
// Sometimes propose returns NaN, so we may need to estimate.
|
||||
terminal.resize(
|
||||
|
||||
@@ -6,17 +6,13 @@ export function useTerminalBuffer() {
|
||||
const serializeAddon = new SerializeAddon()
|
||||
const terminal = markRaw(new Terminal({ convertEol: true }))
|
||||
|
||||
function copyTo(destinationTerminal: Terminal) {
|
||||
const copyTo = (destinationTerminal: Terminal) => {
|
||||
destinationTerminal.write(serializeAddon.serialize())
|
||||
}
|
||||
|
||||
function write(message: string) {
|
||||
return terminal.write(message)
|
||||
}
|
||||
const write = (message: string) => terminal.write(message)
|
||||
|
||||
function serialize() {
|
||||
return serializeAddon.serialize()
|
||||
}
|
||||
const serialize = () => serializeAddon.serialize()
|
||||
|
||||
onMounted(() => {
|
||||
terminal.loadAddon(serializeAddon)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
function openUrl(url: string) {
|
||||
const openUrl = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -124,15 +124,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
* @param task Task to get the matching state object for
|
||||
* @returns The state object for this task
|
||||
*/
|
||||
function getRunner(task: MaintenanceTask) {
|
||||
return taskRunners.value.get(task.id)!
|
||||
}
|
||||
const getRunner = (task: MaintenanceTask) => taskRunners.value.get(task.id)!
|
||||
|
||||
/**
|
||||
* Updates the task list with the latest validation state.
|
||||
* @param validationUpdate Update details passed in by electron
|
||||
*/
|
||||
function processUpdate(validationUpdate: InstallValidation) {
|
||||
const processUpdate = (validationUpdate: InstallValidation) => {
|
||||
lastUpdate.value = validationUpdate
|
||||
const update = validationUpdate as IndexedUpdate
|
||||
isRefreshing.value = true
|
||||
@@ -153,19 +151,19 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
}
|
||||
|
||||
/** Clears the resolved status of tasks (when changing filters) */
|
||||
function clearResolved() {
|
||||
const clearResolved = () => {
|
||||
for (const task of tasks.value) {
|
||||
getRunner(task).resolved &&= false
|
||||
}
|
||||
}
|
||||
|
||||
/** @todo Refreshes Electron tasks only. */
|
||||
async function refreshDesktopTasks() {
|
||||
const refreshDesktopTasks = async () => {
|
||||
isRefreshing.value = true
|
||||
await electron.Validation.validateInstallation(processUpdate)
|
||||
}
|
||||
|
||||
async function execute(task: MaintenanceTask) {
|
||||
const execute = async (task: MaintenanceTask) => {
|
||||
const success = await getRunner(task).execute(task)
|
||||
if (success && task.isInstallationFix) {
|
||||
await refreshDesktopTasks()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { electronAPI } from './envUtil'
|
||||
* @param mirror - The mirror to check.
|
||||
* @returns True if the mirror is reachable, false otherwise.
|
||||
*/
|
||||
export async function checkMirrorReachable(mirror: string) {
|
||||
export const checkMirrorReachable = async (mirror: string) => {
|
||||
return (
|
||||
isValidUrl(mirror) && (await electronAPI().NetWork.canAccessUrl(mirror))
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ import { electronAPI } from '@/utils/envUtil'
|
||||
const route = useRoute()
|
||||
const { id, title, message, buttons } = getDialog(route.params.dialogId)
|
||||
|
||||
async function handleButtonClick(button: DialogAction) {
|
||||
const handleButtonClick = async (button: DialogAction) => {
|
||||
await electronAPI().Dialog.clickButton(button.returnValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,7 +52,7 @@ const electron = electronAPI()
|
||||
|
||||
const terminalVisible = ref(false)
|
||||
|
||||
function toggleConsoleDrawer() {
|
||||
const toggleConsoleDrawer = () => {
|
||||
terminalVisible.value = !terminalVisible.value
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,11 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
function openGitDownloads() {
|
||||
const openGitDownloads = () => {
|
||||
window.open('https://git-scm.com/downloads/', '_blank')
|
||||
}
|
||||
|
||||
async function skipGit() {
|
||||
const skipGit = async () => {
|
||||
console.warn('pushing')
|
||||
const router = useRouter()
|
||||
await router.push('install')
|
||||
|
||||
@@ -8,8 +8,8 @@ import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import InstallView from './InstallView.vue'
|
||||
|
||||
// Create a mock router for stories
|
||||
function createMockRouter() {
|
||||
return createRouter({
|
||||
const createMockRouter = () =>
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div>Home</div>' } },
|
||||
@@ -23,7 +23,6 @@ function createMockRouter() {
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const meta: Meta<typeof InstallView> = {
|
||||
title: 'Desktop/Views/InstallView',
|
||||
|
||||
@@ -90,7 +90,7 @@ const currentStep = ref('1')
|
||||
/** Forces each install step to be visited at least once. */
|
||||
const highestStep = ref(0)
|
||||
|
||||
function handleStepChange(value: string | number) {
|
||||
const handleStepChange = (value: string | number) => {
|
||||
setHighestStep(value)
|
||||
|
||||
electronAPI().Events.trackEvent('install_stepper_change', {
|
||||
@@ -98,7 +98,7 @@ function handleStepChange(value: string | number) {
|
||||
})
|
||||
}
|
||||
|
||||
function setHighestStep(value: string | number) {
|
||||
const setHighestStep = (value: string | number) => {
|
||||
const int = typeof value === 'number' ? value : parseInt(value, 10)
|
||||
if (!isNaN(int) && int > highestStep.value) highestStep.value = int
|
||||
}
|
||||
@@ -123,7 +123,7 @@ const canProceed = computed(() => {
|
||||
})
|
||||
|
||||
// Navigation methods
|
||||
function goToNextStep() {
|
||||
const goToNextStep = () => {
|
||||
const nextStep = (parseInt(currentStep.value) + 1).toString()
|
||||
currentStep.value = nextStep
|
||||
setHighestStep(nextStep)
|
||||
@@ -132,7 +132,7 @@ function goToNextStep() {
|
||||
})
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
const goToPreviousStep = () => {
|
||||
const prevStep = (parseInt(currentStep.value) - 1).toString()
|
||||
currentStep.value = prevStep
|
||||
electronAPI().Events.trackEvent('install_stepper_change', {
|
||||
@@ -142,7 +142,7 @@ function goToPreviousStep() {
|
||||
|
||||
const electron = electronAPI()
|
||||
const router = useRouter()
|
||||
async function install() {
|
||||
const install = async () => {
|
||||
if (!device.value) return
|
||||
|
||||
const options: InstallOptions = {
|
||||
|
||||
@@ -35,14 +35,12 @@ const validationState: ValidationState = {
|
||||
upgradePackages: 'OK'
|
||||
}
|
||||
|
||||
function createMockElectronAPI() {
|
||||
const createMockElectronAPI = () => {
|
||||
const logListeners: Array<(message: string) => void> = []
|
||||
|
||||
function getValidationUpdate() {
|
||||
return {
|
||||
...validationState
|
||||
}
|
||||
}
|
||||
const getValidationUpdate = () => ({
|
||||
...validationState
|
||||
})
|
||||
|
||||
return {
|
||||
getPlatform: () => 'darwin',
|
||||
@@ -78,7 +76,7 @@ function createMockElectronAPI() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureElectronAPI() {
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
|
||||
@@ -183,7 +183,7 @@ const unsafeReasonText = computed(() => {
|
||||
})
|
||||
|
||||
/** If valid, leave the validation window. */
|
||||
async function completeValidation() {
|
||||
const completeValidation = async () => {
|
||||
const isValid = await electron.Validation.complete()
|
||||
if (!isValid) {
|
||||
toast.add({
|
||||
@@ -194,7 +194,7 @@ async function completeValidation() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConsoleDrawer() {
|
||||
const toggleConsoleDrawer = () => {
|
||||
terminalVisible.value = !terminalVisible.value
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,7 @@ const electron = electronAPI()
|
||||
const basePath = ref<string | null>(null)
|
||||
const sep = ref<'\\' | '/'>('/')
|
||||
|
||||
function restartApp(message?: string) {
|
||||
return electron.restartApp(message)
|
||||
}
|
||||
const restartApp = (message?: string) => electron.restartApp(message)
|
||||
|
||||
onMounted(async () => {
|
||||
basePath.value = await electron.getBasePath()
|
||||
|
||||
@@ -64,7 +64,7 @@ const allowMetrics = ref(true)
|
||||
const router = useRouter()
|
||||
const isUpdating = ref(false)
|
||||
|
||||
async function updateConsent() {
|
||||
const updateConsent = async () => {
|
||||
isUpdating.value = true
|
||||
try {
|
||||
await electronAPI().setMetricsConsent(allowMetrics.value)
|
||||
|
||||
@@ -61,19 +61,19 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
function openDocs() {
|
||||
const openDocs = () => {
|
||||
window.open(
|
||||
'https://github.com/Comfy-Org/desktop#currently-supported-platforms',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
function reportIssue() {
|
||||
const reportIssue = () => {
|
||||
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
async function continueToInstall() {
|
||||
const continueToInstall = async () => {
|
||||
await router.push('/install')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -118,7 +118,7 @@ let xterm: Terminal | undefined
|
||||
/**
|
||||
* Handles installation stage updates from the desktop
|
||||
*/
|
||||
function updateInstallStage(stageInfo: InstallStageInfo) {
|
||||
const updateInstallStage = (stageInfo: InstallStageInfo) => {
|
||||
console.warn('[InstallStage.onUpdate] Received:', {
|
||||
stage: stageInfo.stage,
|
||||
progress: stageInfo.progress,
|
||||
@@ -183,17 +183,17 @@ const displayStatusText = computed(() => {
|
||||
return currentStatusLabel.value
|
||||
})
|
||||
|
||||
function updateProgress({ status: newStatus }: { status: ProgressStatus }) {
|
||||
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
|
||||
status.value = newStatus
|
||||
|
||||
// Make critical error screen more obvious.
|
||||
if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false
|
||||
}
|
||||
|
||||
function terminalCreated(
|
||||
const terminalCreated = (
|
||||
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
|
||||
root: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
) => {
|
||||
xterm = terminal
|
||||
|
||||
useAutoSize({ root, autoRows: true, autoCols: true })
|
||||
@@ -206,15 +206,11 @@ function terminalCreated(
|
||||
terminal.options.cursorInactiveStyle = 'block'
|
||||
}
|
||||
|
||||
function troubleshoot() {
|
||||
return electron.startTroubleshooting()
|
||||
}
|
||||
function reportIssue() {
|
||||
const troubleshoot = () => electron.startTroubleshooting()
|
||||
const reportIssue = () => {
|
||||
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
|
||||
}
|
||||
function openLogs() {
|
||||
return electron.openLogsFolder()
|
||||
}
|
||||
const openLogs = () => electron.openLogsFolder()
|
||||
|
||||
let cleanupInstallStageListener: (() => void) | undefined
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { useRouter } from 'vue-router'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const router = useRouter()
|
||||
async function navigateTo(path: string) {
|
||||
const navigateTo = async (path: string) => {
|
||||
await router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,9 +3,8 @@ import { expect, test } from '@playwright/test'
|
||||
import { demos, getNextDemo } from '../src/config/demos'
|
||||
import { t } from '../src/i18n/translations'
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
for (const demo of demos) {
|
||||
|
||||
@@ -111,7 +111,7 @@ async function measureMarqueeLoopGeometry(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
function setAllTimes(time: number) {
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
@@ -119,9 +119,7 @@ async function measureMarqueeLoopGeometry(
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
function readX() {
|
||||
return tracks.map((track) => track.getBoundingClientRect().x)
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
|
||||
@@ -36,9 +36,7 @@ let pendingFrame = 0
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
function deptElementId(key: string) {
|
||||
return `careers-dept-${key}`
|
||||
}
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
|
||||
@@ -58,7 +58,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
raw.sort((a, b) => {
|
||||
function norm(v: number) {
|
||||
const norm = (v: number) => {
|
||||
const r = v + Math.PI / 2
|
||||
return r < 0 ? r + 2 * Math.PI : r
|
||||
}
|
||||
@@ -117,7 +117,7 @@ onMounted(() => {
|
||||
applyToPanel(panels[1], elapsed2)
|
||||
applyToPanel(panels[2], elapsed3)
|
||||
|
||||
function wOf(elapsed: number) {
|
||||
const wOf = (elapsed: number) => {
|
||||
const progress = elapsed < PANEL_DURATION ? elapsed / PANEL_DURATION : 1
|
||||
return lerp(S.w, E.w, ease(progress))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useParallax(
|
||||
|
||||
const triggerEl = options.trigger?.value
|
||||
|
||||
function createAnimations() {
|
||||
const createAnimations = () => {
|
||||
const els = elements
|
||||
.map((r) => r.value)
|
||||
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
|
||||
|
||||
@@ -29,7 +29,7 @@ function interpolateY(
|
||||
contentH: number,
|
||||
vpH: number
|
||||
) {
|
||||
function clampedTarget(i: number) {
|
||||
const clampedTarget = (i: number) => {
|
||||
const center = buttonCenters[i] ?? 0
|
||||
return Math.max(-(contentH - vpH), Math.min(0, vpH / 2 - center))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../data/cloudNodes'
|
||||
|
||||
@@ -8,7 +9,7 @@ import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
@@ -6,7 +7,7 @@ import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
|
||||
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return customerStories.map((story) => ({
|
||||
params: { slug: story.slug }
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
|
||||
@@ -7,7 +8,7 @@ import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
params: { slug: model.slug }
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../../data/cloudNodes'
|
||||
|
||||
@@ -8,7 +9,7 @@ import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
|
||||
export async function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
@@ -6,7 +7,7 @@ import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue
|
||||
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return customerStories.map((story) => ({
|
||||
params: { slug: story.slug }
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
|
||||
@@ -7,7 +8,7 @@ import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export function getStaticPaths() {
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
|
||||
@@ -35,9 +35,8 @@ const TICK_MS = 200
|
||||
|
||||
function readColors() {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
function get(name: string, fallback: string): string {
|
||||
return style.getPropertyValue(name).trim() || fallback
|
||||
}
|
||||
const get = (name: string, fallback: string): string =>
|
||||
style.getPropertyValue(name).trim() || fallback
|
||||
|
||||
return {
|
||||
bg: get('--color-primary-comfy-ink', '#211927'),
|
||||
@@ -60,12 +59,9 @@ function requireElement<T extends Element>(
|
||||
return el
|
||||
}
|
||||
|
||||
function isSVGSVG(el: Element): el is SVGSVGElement {
|
||||
return el instanceof SVGSVGElement
|
||||
}
|
||||
function isSVGG(el: Element): el is SVGGElement {
|
||||
return el instanceof SVGGElement
|
||||
}
|
||||
const isSVGSVG = (el: Element): el is SVGSVGElement =>
|
||||
el instanceof SVGSVGElement
|
||||
const isSVGG = (el: Element): el is SVGGElement => el instanceof SVGGElement
|
||||
function isSVGText(el: Element): el is SVGTextElement {
|
||||
return el instanceof SVGTextElement
|
||||
}
|
||||
@@ -131,9 +127,8 @@ function depth(cell: Cell): number {
|
||||
|
||||
function roundedPath(pts: [number, number][], radius: number): string {
|
||||
const n = pts.length
|
||||
function fmt(p: readonly [number, number]) {
|
||||
return `${p[0].toFixed(2)},${p[1].toFixed(2)}`
|
||||
}
|
||||
const fmt = (p: readonly [number, number]) =>
|
||||
`${p[0].toFixed(2)},${p[1].toFixed(2)}`
|
||||
let d = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = pts[(i - 1 + n) % n]
|
||||
@@ -211,7 +206,7 @@ function triggerExplosion() {
|
||||
const cx = ((COLS - ROWS) * STEP_X) / 2
|
||||
const cy = ((COLS + ROWS - 2) * STEP_Y) / 2
|
||||
|
||||
function launchParticle(i: number, j: number, fill: string): Particle {
|
||||
const launchParticle = (i: number, j: number, fill: string): Particle => {
|
||||
const [sx, sy] = iso(i, j)
|
||||
const baseAngle = Math.atan2(sy - cy, sx - cx)
|
||||
const angle = baseAngle + (Math.random() - 0.5) * 1.2
|
||||
@@ -244,9 +239,7 @@ function triggerExplosion() {
|
||||
const DROP_DURATION_MS = 450
|
||||
const DROP_HEIGHT = 600
|
||||
let foodDropStart = 0
|
||||
function easeOutCubic(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
|
||||
|
||||
function foodDropOffset(now = performance.now()): number {
|
||||
if (!foodDropStart) return 0
|
||||
@@ -259,7 +252,7 @@ function foodDropOffset(now = performance.now()): number {
|
||||
const REBIRTH_STAGGER_MS = 90
|
||||
const REBIRTH_GROW_MS = 260
|
||||
let rebirthStart = 0
|
||||
function easeOutBack(t: number) {
|
||||
const easeOutBack = (t: number) => {
|
||||
const c1 = 1.70158
|
||||
const c3 = c1 + 1
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
|
||||
@@ -278,9 +271,7 @@ function rebirthScaleFor(idx: number, now = performance.now()): number {
|
||||
const CHOMP_DURATION_MS = 220
|
||||
const CHOMP_PEAK_SCALE = 1.15
|
||||
let chompStart = 0
|
||||
function easeOut(t: number) {
|
||||
return 1 - (1 - t) * (1 - t)
|
||||
}
|
||||
const easeOut = (t: number) => 1 - (1 - t) * (1 - t)
|
||||
|
||||
function chompScale(now = performance.now()): number {
|
||||
if (!chompStart) return 1
|
||||
@@ -308,7 +299,7 @@ function isAnimating(): boolean {
|
||||
|
||||
function ensureAnimationLoop() {
|
||||
if (animationHandle !== null) return
|
||||
function tick() {
|
||||
const tick = () => {
|
||||
if (
|
||||
explodeStart &&
|
||||
performance.now() - explodeStart >= EXPLODE_DURATION_MS
|
||||
@@ -420,12 +411,8 @@ function updateScoreDisplay() {
|
||||
scoreBestEl.textContent = String(best)
|
||||
}
|
||||
|
||||
function cellsEqual(a: Cell, b: Cell) {
|
||||
return a.i === b.i && a.j === b.j
|
||||
}
|
||||
function inBounds(c: Cell) {
|
||||
return c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
|
||||
}
|
||||
const cellsEqual = (a: Cell, b: Cell) => a.i === b.i && a.j === b.j
|
||||
const inBounds = (c: Cell) => c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
|
||||
|
||||
function reset() {
|
||||
const j0 = Math.floor(ROWS / 2)
|
||||
|
||||
103
browser_tests/fixtures/components/AssetBrowserModal.ts
Normal file
103
browser_tests/fixtures/components/AssetBrowserModal.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class AssetBrowserModal {
|
||||
public readonly root: Locator
|
||||
public readonly assetGrid: Locator
|
||||
public readonly modelInfoPanel: Locator
|
||||
|
||||
public readonly basicInfoSection: Locator
|
||||
public readonly modelTaggingSection: Locator
|
||||
public readonly modelDescriptionSection: Locator
|
||||
|
||||
public readonly displayNameText: Locator
|
||||
public readonly editDisplayNameButton: Locator
|
||||
public readonly displayNameInput: Locator
|
||||
public readonly fileNameText: Locator
|
||||
public readonly sourceLink: Locator
|
||||
public readonly modelTypeSelect: Locator
|
||||
public readonly baseModelsField: Locator
|
||||
public readonly additionalTagsField: Locator
|
||||
public readonly baseModelsInput: Locator
|
||||
public readonly additionalTagsInput: Locator
|
||||
public readonly descriptionText: Locator
|
||||
public readonly userDescriptionTextarea: Locator
|
||||
public readonly triggerPhrasesCopyAllButton: Locator
|
||||
public readonly triggerPhraseButtons: Locator
|
||||
public readonly selectModelPrompt: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.locator('[data-component-id="AssetBrowserModal"]')
|
||||
this.assetGrid = this.root.locator('[data-component-id="AssetGrid"]')
|
||||
this.modelInfoPanel = page.locator('[data-component-id="ModelInfoPanel"]')
|
||||
|
||||
const sections = this.modelInfoPanel.locator(':scope > div')
|
||||
this.basicInfoSection = sections.nth(0)
|
||||
this.modelTaggingSection = sections.nth(1)
|
||||
this.modelDescriptionSection = sections.nth(2)
|
||||
|
||||
this.displayNameText = this.basicInfoSection
|
||||
.locator('.editable-text')
|
||||
.first()
|
||||
this.editDisplayNameButton = this.basicInfoSection.getByRole('button', {
|
||||
name: /edit/i
|
||||
})
|
||||
this.displayNameInput = this.basicInfoSection.locator('input[type="text"]')
|
||||
this.fileNameText = this.basicInfoSection
|
||||
.locator('span.break-all.text-muted-foreground')
|
||||
.first()
|
||||
this.sourceLink = this.basicInfoSection
|
||||
.locator('a[target="_blank"]')
|
||||
.first()
|
||||
|
||||
this.modelTypeSelect = this.modelTaggingSection.getByRole('combobox')
|
||||
this.baseModelsField = this.modelTaggingSection
|
||||
.locator('div')
|
||||
.filter({ hasText: /base model/i })
|
||||
.first()
|
||||
this.additionalTagsField = this.modelTaggingSection
|
||||
.locator('div')
|
||||
.filter({ hasText: /additional tag/i })
|
||||
.first()
|
||||
this.baseModelsInput = this.baseModelsField.locator('input')
|
||||
this.additionalTagsInput = this.additionalTagsField.locator('input')
|
||||
|
||||
this.descriptionText = this.modelDescriptionSection.locator('p').first()
|
||||
this.userDescriptionTextarea =
|
||||
this.modelDescriptionSection.locator('textarea')
|
||||
this.triggerPhrasesCopyAllButton = this.modelDescriptionSection.getByRole(
|
||||
'button',
|
||||
{ name: /copy all/i }
|
||||
)
|
||||
this.triggerPhraseButtons = this.modelDescriptionSection
|
||||
.locator('button')
|
||||
.filter({ hasText: /.+/ })
|
||||
|
||||
this.selectModelPrompt = this.root.locator('.wrap-break-word.text-muted')
|
||||
}
|
||||
|
||||
async clickAsset(name: string, assetId?: string): Promise<void> {
|
||||
const assetCard = assetId
|
||||
? this.assetGrid.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${assetId}"]`
|
||||
)
|
||||
: this.assetGrid.locator('[data-component-id="AssetCard"]').filter({
|
||||
has: this.page.getByRole('heading', {
|
||||
name,
|
||||
exact: true
|
||||
})
|
||||
})
|
||||
|
||||
await assetCard.first().click()
|
||||
}
|
||||
|
||||
async waitForModelInfoPanel(): Promise<void> {
|
||||
await this.modelInfoPanel.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async waitForAssetContent(text: string): Promise<void> {
|
||||
await this.modelInfoPanel
|
||||
.getByText(text, { exact: false })
|
||||
.first()
|
||||
.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
64
browser_tests/fixtures/data/assetBrowserFixtures.ts
Normal file
64
browser_tests/fixtures/data/assetBrowserFixtures.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
|
||||
function createAssetBrowserModel(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'browser-model-001',
|
||||
name: 'test_model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2025-01-15T10:00:00Z',
|
||||
updated_at: '2025-01-15T10:00:00Z',
|
||||
last_access_time: '2025-01-15T10:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const EDITABLE_MODEL: Asset = createAssetBrowserModel({
|
||||
id: 'browser-model-editable-001',
|
||||
name: 'cinematic_details_v2.safetensors',
|
||||
tags: ['models', 'loras'],
|
||||
is_immutable: false,
|
||||
metadata: {
|
||||
description: 'A cinematic detail enhancer LoRA tuned for portraits.',
|
||||
source_arn: 'civitai:model:12345:version:67890',
|
||||
trained_words: ['cinematic lighting', 'sharp details', 'portrait glow'],
|
||||
filename: 'cinematic_details_v2.safetensors'
|
||||
},
|
||||
user_metadata: {
|
||||
name: 'Cinematic Details v2',
|
||||
base_model: ['sdxl', 'flux.1-dev'],
|
||||
additional_tags: ['portrait', 'detail'],
|
||||
user_description: 'Great for close-up portraits and high-frequency details.'
|
||||
}
|
||||
})
|
||||
|
||||
export const IMMUTABLE_MODEL: Asset = createAssetBrowserModel({
|
||||
id: 'browser-model-immutable-001',
|
||||
name: 'sdxl_base_1.0.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
is_immutable: true,
|
||||
metadata: {
|
||||
description: 'Official SDXL base checkpoint from Hugging Face.',
|
||||
repo_url: 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0'
|
||||
},
|
||||
user_metadata: {}
|
||||
})
|
||||
|
||||
export const BARE_MODEL: Asset = createAssetBrowserModel({
|
||||
id: 'browser-model-bare-001',
|
||||
name: 'bare_checkpoint.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
is_immutable: false,
|
||||
metadata: {},
|
||||
user_metadata: {}
|
||||
})
|
||||
|
||||
export const MOCK_MODEL_FOLDERS: Array<{ name: string; folders: string[] }> = [
|
||||
{ name: 'checkpoints', folders: ['main'] },
|
||||
{ name: 'loras', folders: ['style', 'detail'] },
|
||||
{ name: 'vae', folders: ['default'] },
|
||||
{ name: 'controlnet', folders: ['canny', 'depth'] }
|
||||
]
|
||||
146
browser_tests/fixtures/helpers/AssetBrowserHelper.ts
Normal file
146
browser_tests/fixtures/helpers/AssetBrowserHelper.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
|
||||
export type TagMutationCall = {
|
||||
method: string
|
||||
assetId: string
|
||||
body: { tags: string[] }
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const modelFoldersRoutePattern = /\/api\/experiment\/models(?:\?.*)?$/
|
||||
const assetTagsRoutePattern = /\/api\/assets\/([^/]+)\/tags(?:\?.*)?$/
|
||||
|
||||
export class AssetBrowserHelper {
|
||||
private readonly routeHandlers: Array<{
|
||||
pattern: string | RegExp
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockModelFolders(
|
||||
folders: Array<{ name: string; folders: string[] }>
|
||||
): Promise<void> {
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(folders)
|
||||
})
|
||||
}
|
||||
|
||||
this.routeHandlers.push({ pattern: modelFoldersRoutePattern, handler })
|
||||
await this.page.route(modelFoldersRoutePattern, handler)
|
||||
}
|
||||
|
||||
async mockAssetTags(
|
||||
initialAssets?: Array<{ id: string; tags: string[] }>
|
||||
): Promise<{ getCalls(): TagMutationCall[] }> {
|
||||
const calls: TagMutationCall[] = []
|
||||
const tagsByAssetId = new Map<string, string[]>()
|
||||
|
||||
if (initialAssets) {
|
||||
for (const asset of initialAssets) {
|
||||
tagsByAssetId.set(asset.id, [...asset.tags])
|
||||
}
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
if (method !== 'POST' && method !== 'DELETE') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const match = request.url().match(assetTagsRoutePattern)
|
||||
const assetId = match?.[1]
|
||||
if (!assetId) {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
|
||||
const rawBody = request.postDataJSON() as { tags?: unknown }
|
||||
const tags = Array.isArray(rawBody?.tags)
|
||||
? rawBody.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||
: []
|
||||
|
||||
const body = { tags }
|
||||
calls.push({
|
||||
method,
|
||||
assetId,
|
||||
body,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
const existing = tagsByAssetId.get(assetId) ?? ['models']
|
||||
const totalTags =
|
||||
method === 'POST'
|
||||
? Array.from(new Set([...existing, ...tags]))
|
||||
: existing.filter((tag) => !tags.includes(tag))
|
||||
|
||||
const added =
|
||||
method === 'POST'
|
||||
? totalTags.filter((tag) => !existing.includes(tag))
|
||||
: []
|
||||
const removed =
|
||||
method === 'DELETE'
|
||||
? existing.filter((tag) => !totalTags.includes(tag))
|
||||
: []
|
||||
|
||||
tagsByAssetId.set(assetId, totalTags)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
total_tags: totalTags,
|
||||
added,
|
||||
removed
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
this.routeHandlers.push({ pattern: assetTagsRoutePattern, handler })
|
||||
await this.page.route(assetTagsRoutePattern, handler)
|
||||
|
||||
return {
|
||||
getCalls: () => [...calls]
|
||||
}
|
||||
}
|
||||
|
||||
async enableAssetApiSetting(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.setting.set(
|
||||
'Comfy.Assets.UseAssetAPI',
|
||||
true
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async openModelLibrary(): Promise<void> {
|
||||
await this.page.evaluate(async () => {
|
||||
await window.app!.extensionManager.command.execute(
|
||||
'Comfy.BrowseModelAssets'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assetToDisplayName(asset: Asset): string {
|
||||
if (typeof asset.user_metadata?.name === 'string') {
|
||||
return asset.user_metadata.name
|
||||
}
|
||||
if (typeof asset.metadata?.name === 'string') {
|
||||
return asset.metadata.name
|
||||
}
|
||||
return asset.name
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export class AssetHelper {
|
||||
statusCode: number,
|
||||
error: string = 'Internal Server Error'
|
||||
): Promise<void> {
|
||||
async function handler(route: Route) {
|
||||
const handler = async (route: Route) => {
|
||||
return route.fulfill({
|
||||
status: statusCode,
|
||||
json: { error }
|
||||
|
||||
@@ -325,7 +325,7 @@ export class AssetsHelper {
|
||||
await this.page.unroute(pattern, existingHandler)
|
||||
}
|
||||
|
||||
async function handler(route: Route) {
|
||||
const handler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function setupNodeReplacement(
|
||||
options?: AddEventListenerOptions | boolean
|
||||
) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
function wrapped(this: WebSocket, event: Event) {
|
||||
const wrapped = function (this: WebSocket, event: Event) {
|
||||
const msgEvent = event as MessageEvent
|
||||
if (typeof msgEvent.data === 'string') {
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
@@ -242,17 +241,6 @@ export class SubgraphHelper {
|
||||
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
|
||||
}
|
||||
|
||||
async getInputBounds(): Promise<Position & Size> {
|
||||
return await this.comfyPage.page.evaluate(() => {
|
||||
const graph = app!.canvas.graph as Subgraph
|
||||
const inputNode = graph.inputNode
|
||||
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
|
||||
const width = inputNode.size[0] * app!.canvas.ds.scale
|
||||
const height = inputNode.size[1] * app!.canvas.ds.scale
|
||||
return { x, y, width, height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
@@ -618,7 +606,7 @@ export class SubgraphHelper {
|
||||
]
|
||||
): { warnings: string[]; dispose: () => void } {
|
||||
const warnings: string[] = []
|
||||
function handler(msg: ConsoleMessage) {
|
||||
const handler = (msg: ConsoleMessage) => {
|
||||
const text = msg.text()
|
||||
if (patterns.some((p) => text.includes(p))) {
|
||||
warnings.push(text)
|
||||
|
||||
@@ -150,7 +150,7 @@ export class TemplateHelper {
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
async function thumbnailHandler(route: Route) {
|
||||
const thumbnailHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
|
||||
@@ -40,7 +40,7 @@ interface JobsListRoute {
|
||||
responseLimit?: number
|
||||
}
|
||||
|
||||
export interface JobsScenario {
|
||||
interface JobsScenario {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
}
|
||||
|
||||
@@ -76,15 +76,7 @@ export const TestIds = {
|
||||
publishTabPanel: 'publish-tab-panel',
|
||||
apiSignin: 'api-signin-dialog',
|
||||
updatePassword: 'update-password-dialog',
|
||||
cloudNotification: 'cloud-notification-dialog',
|
||||
openSharedWorkflow: 'open-shared-workflow-dialog',
|
||||
openSharedWorkflowTitle: 'open-shared-workflow-title',
|
||||
openSharedWorkflowClose: 'open-shared-workflow-close',
|
||||
openSharedWorkflowErrorClose: 'open-shared-workflow-error-close',
|
||||
openSharedWorkflowCancel: 'open-shared-workflow-cancel',
|
||||
openSharedWorkflowOpenWithoutImporting:
|
||||
'open-shared-workflow-open-without-importing',
|
||||
openSharedWorkflowConfirm: 'open-shared-workflow-confirm'
|
||||
cloudNotification: 'cloud-notification-dialog'
|
||||
},
|
||||
keybindings: {
|
||||
presetMenu: 'keybinding-preset-menu'
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ImportPublishedAssetsRequest,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
export const sharedWorkflowImportScenario = {
|
||||
shareId: 'shared-missing-media-e2e',
|
||||
workflowId: 'shared-missing-media-workflow',
|
||||
publishedAssetId: 'published-input-asset-1',
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
export interface SharedWorkflowImportMocks {
|
||||
resetAndStartRecording: () => void
|
||||
getImportBody: () => ImportPublishedAssetsRequest | undefined
|
||||
getRequestEvents: () => SharedWorkflowRequestEvent[]
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () => Promise<void>
|
||||
}
|
||||
|
||||
const defaultInputFileName = '00000000000000000000000Aexample.png'
|
||||
|
||||
const sharedWorkflowAsset: AssetInfo = {
|
||||
id: sharedWorkflowImportScenario.publishedAssetId,
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
preview_url: '',
|
||||
storage_url: '',
|
||||
model: false,
|
||||
public: false,
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const sharedWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: sharedWorkflowImportScenario.shareId,
|
||||
workflow_id: sharedWorkflowImportScenario.workflowId,
|
||||
name: 'Shared Missing Media Workflow',
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 10,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'LoadImage',
|
||||
pos: [50, 200],
|
||||
size: [315, 314],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
links: null
|
||||
},
|
||||
{
|
||||
name: 'MASK',
|
||||
type: 'MASK',
|
||||
links: null
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'LoadImage'
|
||||
},
|
||||
widgets_values: [sharedWorkflowImportScenario.inputFileName, 'image']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
assets: [sharedWorkflowAsset]
|
||||
}
|
||||
|
||||
export const sharedWorkflowImportFixture = base.extend<{
|
||||
sharedWorkflowImportMocks: SharedWorkflowImportMocks
|
||||
}>({
|
||||
sharedWorkflowImportMocks: async ({ page }, use) => {
|
||||
const mocks = await mockSharedWorkflowImportFlow(page)
|
||||
await use(mocks)
|
||||
}
|
||||
})
|
||||
|
||||
async function mockSharedWorkflowImportFlow(
|
||||
page: Page
|
||||
): Promise<SharedWorkflowImportMocks> {
|
||||
function noopResolveResponse() {}
|
||||
let isRecording = false
|
||||
let importEndpointCalled = false
|
||||
let importBody: ImportPublishedAssetsRequest | undefined
|
||||
let resolvePublicInclusiveInputAssetResponseAfterImport: () => void =
|
||||
noopResolveResponse
|
||||
let publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
const requestEvents: SharedWorkflowRequestEvent[] = []
|
||||
|
||||
function resetPublicInclusiveInputAssetResponseWaiter() {
|
||||
publicInclusiveInputAssetResponseAfterImport = new Promise<void>(
|
||||
(resolve) => {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport = resolve
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function recordRequestEvent(event: SharedWorkflowRequestEvent) {
|
||||
if (isRecording) requestEvents.push(event)
|
||||
}
|
||||
|
||||
await page.route(
|
||||
`**/workflows/published/${sharedWorkflowImportScenario.shareId}`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(sharedWorkflowResponse)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await page.route('**/api/assets/import', async (route) => {
|
||||
recordRequestEvent('import')
|
||||
importBody = route.request().postDataJSON() as ImportPublishedAssetsRequest
|
||||
importEndpointCalled = true
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
|
||||
// Excludes `/api/assets/import` so the specific route above
|
||||
// remains isolated from the general asset listing mock.
|
||||
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const includeTags = getTagParam(url, 'include_tags')
|
||||
const isInputAssetRequest = includeTags.includes('input')
|
||||
const includesPublicAssets =
|
||||
url.searchParams.get('include_public') === 'true'
|
||||
const isPublicInclusiveInputAssetRequest =
|
||||
isInputAssetRequest && includesPublicAssets
|
||||
const isAfterImportPublicInclusiveInputAssetRequest =
|
||||
isPublicInclusiveInputAssetRequest && importEndpointCalled
|
||||
|
||||
if (isPublicInclusiveInputAssetRequest) {
|
||||
recordRequestEvent(
|
||||
importEndpointCalled
|
||||
? 'input-assets-including-public-after-import'
|
||||
: 'input-assets-including-public-before-import'
|
||||
)
|
||||
}
|
||||
|
||||
const allAssets = [
|
||||
defaultInputAsset,
|
||||
...(importEndpointCalled ? [importedInputAsset] : [])
|
||||
]
|
||||
const assets = includeTags.length
|
||||
? allAssets.filter((asset) =>
|
||||
includeTags.every((tag) => asset.tags?.includes(tag))
|
||||
)
|
||||
: allAssets
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: false
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
|
||||
if (isAfterImportPublicInclusiveInputAssetRequest) {
|
||||
resolvePublicInclusiveInputAssetResponseAfterImport()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
resetAndStartRecording: () => {
|
||||
isRecording = true
|
||||
importEndpointCalled = false
|
||||
importBody = undefined
|
||||
requestEvents.length = 0
|
||||
resetPublicInclusiveInputAssetResponseWaiter()
|
||||
},
|
||||
getImportBody: () => importBody,
|
||||
getRequestEvents: () => [...requestEvents],
|
||||
waitForPublicInclusiveInputAssetResponseAfterImport: () =>
|
||||
publicInclusiveInputAssetResponseAfterImport
|
||||
}
|
||||
}
|
||||
|
||||
function getTagParam(url: URL, key: string): string[] {
|
||||
return (
|
||||
url.searchParams
|
||||
.get(key)
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export function getMiddlePoint(pos1: Position, pos2: Position) {
|
||||
export const getMiddlePoint = (pos1: Position, pos2: Position) => {
|
||||
return {
|
||||
x: (pos1.x + pos2.x) / 2,
|
||||
y: (pos1.y + pos2.y) / 2
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export async function openMoreOptionsMenu(
|
||||
comfyPage: ComfyPage,
|
||||
nodeTitle: string
|
||||
) {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
if (nodes.length === 0) {
|
||||
throw new Error(`No "${nodeTitle}" nodes found`)
|
||||
}
|
||||
|
||||
await nodes[0].centerOnNode()
|
||||
await nodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
return menu
|
||||
}
|
||||
@@ -45,7 +45,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
// Find and set the width on the latent node
|
||||
async function triggerChange(value: number) {
|
||||
const triggerChange = async (value: number) => {
|
||||
return await comfyPage.page.evaluate((value) => {
|
||||
const node = window.app!.graph!._nodes.find(
|
||||
(n) => n.type === 'EmptyLatentImage'
|
||||
@@ -59,7 +59,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
}
|
||||
|
||||
// Trigger a status websocket message
|
||||
function triggerStatus(queueSize: number) {
|
||||
const triggerStatus = (queueSize: number) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
@@ -75,7 +75,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
}
|
||||
|
||||
// Extract the width from the queue response
|
||||
async function getQueuedWidth(resp: Promise<Response>) {
|
||||
const getQueuedWidth = async (resp: Promise<Response>) => {
|
||||
const obj = await (await resp).json()
|
||||
return obj['__request']['prompt']['5']['inputs']['width']
|
||||
}
|
||||
|
||||
25
browser_tests/tests/assetBrowser/AGENTS.md
Normal file
25
browser_tests/tests/assetBrowser/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Asset Browser E2E Tests
|
||||
|
||||
Tests for the Asset Browser modal right panel (`ModelInfoPanel.vue`).
|
||||
|
||||
## Structure
|
||||
|
||||
| File | Coverage |
|
||||
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `modelInfoPanel.spec.ts` | Rendering, mutable/immutable behavior, editing flows, watcher resets, debounce |
|
||||
|
||||
## Shared Test Utilities
|
||||
|
||||
- `@e2e/fixtures/components/AssetBrowserModal` — Page object for modal/root grid
|
||||
and all ModelInfoPanel locators.
|
||||
- `@e2e/fixtures/helpers/AssetBrowserHelper` — Route mocks for endpoints not
|
||||
covered by `AssetHelper` (`GET /experiment/models`, `POST/DELETE /assets/:id/tags`).
|
||||
- `@e2e/fixtures/data/assetBrowserFixtures` — Typed fixtures for editable,
|
||||
immutable, and bare model states.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Set all route mocks **before** `await comfyPage.setup()` so startup fetches hit
|
||||
the mocked endpoints.
|
||||
- Use `expect.poll()` for debounced behavior assertions (metadata and tags updates).
|
||||
- Do not use `waitForTimeout()`.
|
||||
514
browser_tests/tests/assetBrowser/modelInfoPanel.spec.ts
Normal file
514
browser_tests/tests/assetBrowser/modelInfoPanel.spec.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { AssetBrowserModal } from '@e2e/fixtures/components/AssetBrowserModal'
|
||||
import {
|
||||
BARE_MODEL,
|
||||
EDITABLE_MODEL,
|
||||
IMMUTABLE_MODEL,
|
||||
MOCK_MODEL_FOLDERS
|
||||
} from '@e2e/fixtures/data/assetBrowserFixtures'
|
||||
import {
|
||||
assetToDisplayName,
|
||||
AssetBrowserHelper
|
||||
} from '@e2e/fixtures/helpers/AssetBrowserHelper'
|
||||
import type { TagMutationCall } from '@e2e/fixtures/helpers/AssetBrowserHelper'
|
||||
import { withAsset } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
type MetadataBody = {
|
||||
user_metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.describe('Asset Browser - ModelInfoPanel', () => {
|
||||
let modal: AssetBrowserModal
|
||||
let assetBrowserHelper: AssetBrowserHelper
|
||||
let tagCalls: { getCalls(): TagMutationCall[] }
|
||||
|
||||
async function focusEditableModel() {
|
||||
await modal.clickAsset(
|
||||
assetToDisplayName(EDITABLE_MODEL),
|
||||
EDITABLE_MODEL.id
|
||||
)
|
||||
await modal.waitForAssetContent('cinematic_details_v2.safetensors')
|
||||
}
|
||||
|
||||
async function focusImmutableModel() {
|
||||
await modal.clickAsset(
|
||||
assetToDisplayName(IMMUTABLE_MODEL),
|
||||
IMMUTABLE_MODEL.id
|
||||
)
|
||||
await modal.waitForAssetContent('sdxl_base_1.0.safetensors')
|
||||
}
|
||||
|
||||
async function focusBareModel() {
|
||||
await modal.clickAsset(assetToDisplayName(BARE_MODEL), BARE_MODEL.id)
|
||||
await modal.waitForAssetContent('bare_checkpoint.safetensors')
|
||||
}
|
||||
|
||||
function metadataMutations(comfyPage: {
|
||||
assetApi: {
|
||||
getMutations(): Array<{ method: string; endpoint: string; body: unknown }>
|
||||
}
|
||||
}) {
|
||||
return comfyPage.assetApi
|
||||
.getMutations()
|
||||
.filter((mutation) => mutation.method === 'PUT')
|
||||
.filter((mutation) => /\/assets\/[^/]+$/.test(mutation.endpoint))
|
||||
}
|
||||
|
||||
function getLastMetadataBody(comfyPage: {
|
||||
assetApi: {
|
||||
getMutations(): Array<{ method: string; endpoint: string; body: unknown }>
|
||||
}
|
||||
}): MetadataBody | undefined {
|
||||
const list = metadataMutations(comfyPage)
|
||||
const last = list[list.length - 1]
|
||||
if (!last) return undefined
|
||||
return (last.body ?? undefined) as MetadataBody | undefined
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
comfyPage.assetApi.configure(
|
||||
withAsset(EDITABLE_MODEL),
|
||||
withAsset(IMMUTABLE_MODEL),
|
||||
withAsset(BARE_MODEL)
|
||||
)
|
||||
await comfyPage.assetApi.mock()
|
||||
|
||||
assetBrowserHelper = new AssetBrowserHelper(comfyPage.page)
|
||||
await assetBrowserHelper.mockModelFolders(MOCK_MODEL_FOLDERS)
|
||||
tagCalls = await assetBrowserHelper.mockAssetTags([
|
||||
{ id: EDITABLE_MODEL.id, tags: [...(EDITABLE_MODEL.tags ?? [])] },
|
||||
{ id: IMMUTABLE_MODEL.id, tags: [...(IMMUTABLE_MODEL.tags ?? [])] },
|
||||
{ id: BARE_MODEL.id, tags: [...(BARE_MODEL.tags ?? [])] }
|
||||
])
|
||||
|
||||
await comfyPage.setup()
|
||||
await assetBrowserHelper.enableAssetApiSetting()
|
||||
await assetBrowserHelper.openModelLibrary()
|
||||
|
||||
modal = new AssetBrowserModal(comfyPage.page)
|
||||
await expect(modal.root).toBeVisible()
|
||||
|
||||
await focusEditableModel()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await assetBrowserHelper.clearMocks()
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test.describe('1) Panel Rendering & Basic Info', () => {
|
||||
test('shows panel after focusing an asset', async () => {
|
||||
await expect(modal.modelInfoPanel).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders display name text', async () => {
|
||||
await expect(modal.displayNameText).toContainText('Cinematic Details v2')
|
||||
})
|
||||
|
||||
test('renders filename from metadata filename', async () => {
|
||||
await expect(modal.fileNameText).toContainText(
|
||||
'cinematic_details_v2.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders source link for editable model', async () => {
|
||||
await expect(modal.sourceLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('maps civitai source_arn to expected URL', async () => {
|
||||
await expect(modal.sourceLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://civitai.com/models/12345?modelVersionId=67890'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders trigger phrases copy-all button', async () => {
|
||||
await expect(modal.triggerPhrasesCopyAllButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders trigger phrase buttons', async () => {
|
||||
await expect
|
||||
.poll(() => modal.triggerPhraseButtons.count())
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('renders metadata description paragraph', async () => {
|
||||
await expect(modal.descriptionText).toContainText(
|
||||
'cinematic detail enhancer'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders user description in textarea', async () => {
|
||||
await expect(modal.userDescriptionTextarea).toHaveValue(
|
||||
'Great for close-up portraits and high-frequency details.'
|
||||
)
|
||||
})
|
||||
|
||||
test('hides optional metadata blocks for bare model', async () => {
|
||||
await focusBareModel()
|
||||
await expect(modal.sourceLink).toBeHidden()
|
||||
await expect(modal.descriptionText).toBeHidden()
|
||||
await expect(modal.triggerPhrasesCopyAllButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('2) Immutable vs Mutable', () => {
|
||||
test('hides display-name edit button for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.editDisplayNameButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('does not render model type combobox for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.modelTypeSelect).toBeHidden()
|
||||
})
|
||||
|
||||
test('disables base-model tags input for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.baseModelsInput).toBeDisabled()
|
||||
})
|
||||
|
||||
test('disables additional-tags input for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.additionalTagsInput).toBeDisabled()
|
||||
})
|
||||
|
||||
test('disables user description textarea for immutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await expect(modal.userDescriptionTextarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('shows edit controls for mutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await focusEditableModel()
|
||||
await expect(modal.editDisplayNameButton).toBeVisible()
|
||||
await expect(modal.modelTypeSelect).toBeVisible()
|
||||
})
|
||||
|
||||
test('enables user description textarea for mutable asset', async () => {
|
||||
await focusImmutableModel()
|
||||
await focusEditableModel()
|
||||
await expect(modal.userDescriptionTextarea).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('3) Display Name Editing', () => {
|
||||
test('enters edit mode on display-name double-click', async () => {
|
||||
await modal.displayNameText.dblclick()
|
||||
await expect(modal.displayNameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('enters edit mode on edit button click', async () => {
|
||||
await modal.editDisplayNameButton.click()
|
||||
await expect(modal.displayNameInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('submitting new display name sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('My Renamed Model')
|
||||
await modal.displayNameInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.name).toBe('My Renamed Model')
|
||||
})
|
||||
|
||||
test('submitting same display name does not send metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('Cinematic Details v2')
|
||||
await modal.displayNameInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
|
||||
test('canceling display-name edit restores original text', async () => {
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('Temporary Name')
|
||||
await modal.displayNameInput.press('Escape')
|
||||
|
||||
await expect(modal.displayNameText).toContainText('Cinematic Details v2')
|
||||
await expect(modal.displayNameInput).toBeHidden()
|
||||
})
|
||||
|
||||
test('submitting empty display name does not send metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.editDisplayNameButton.click()
|
||||
await modal.displayNameInput.fill('')
|
||||
await modal.displayNameInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('4) Model Type Selection', () => {
|
||||
test('shows model type options when combobox is opened', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await modal.modelTypeSelect.click()
|
||||
await expect(comfyPage.page.getByRole('option')).not.toHaveCount(0)
|
||||
})
|
||||
|
||||
test('changing model type sends tag mutation requests', async () => {
|
||||
const initial = tagCalls.getCalls().length
|
||||
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => tagCalls.getCalls().length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastCall = tagCalls.getCalls().at(-1)
|
||||
expect(lastCall).toBeDefined()
|
||||
expect(lastCall?.body.tags).toContain('checkpoints')
|
||||
})
|
||||
|
||||
test('selecting same model type does not send tag mutations', async () => {
|
||||
const initial = tagCalls.getCalls().length
|
||||
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /lora/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => tagCalls.getCalls().length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
|
||||
test('updates combobox value immediately after selecting new model type', async () => {
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
await expect(modal.modelTypeSelect).toContainText(/checkpoints/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('5) Base Models & Additional Tags', () => {
|
||||
test('shows existing base model values', async () => {
|
||||
await expect(modal.modelTaggingSection).toContainText('sdxl')
|
||||
await expect(modal.modelTaggingSection).toContainText('flux.1-dev')
|
||||
})
|
||||
|
||||
test('shows existing additional tags values', async () => {
|
||||
await expect(modal.modelTaggingSection).toContainText('portrait')
|
||||
await expect(modal.modelTaggingSection).toContainText('detail')
|
||||
})
|
||||
|
||||
test('adding a base model sends metadata update', async ({ comfyPage }) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.baseModelsInput.click()
|
||||
await modal.baseModelsInput.fill('sd3.5-large')
|
||||
await modal.baseModelsInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const baseModels = lastBody?.user_metadata?.base_model as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(baseModels).toContain('sd3.5-large')
|
||||
expect(baseModels).toContain('sdxl')
|
||||
expect(baseModels).toContain('flux.1-dev')
|
||||
})
|
||||
|
||||
test('removing a base model sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
const removeButtons = modal.baseModelsField.getByRole('button', {
|
||||
name: /remove/i
|
||||
})
|
||||
await removeButtons.first().click()
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const baseModels = lastBody?.user_metadata?.base_model as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(baseModels).toBeDefined()
|
||||
expect(baseModels!.length).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('adding an additional tag sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.additionalTagsInput.click()
|
||||
await modal.additionalTagsInput.fill('cinematic')
|
||||
await modal.additionalTagsInput.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const tags = lastBody?.user_metadata?.additional_tags as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(tags).toContain('cinematic')
|
||||
expect(tags).toContain('portrait')
|
||||
expect(tags).toContain('detail')
|
||||
})
|
||||
|
||||
test('removing an additional tag sends metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
const removeButtons = modal.additionalTagsField.getByRole('button', {
|
||||
name: /remove/i
|
||||
})
|
||||
await removeButtons.first().click()
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
const tags = lastBody?.user_metadata?.additional_tags as
|
||||
| string[]
|
||||
| undefined
|
||||
expect(tags).toBeDefined()
|
||||
expect(tags!.length).toBeLessThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('6) User Description', () => {
|
||||
test('shows existing user description value', async () => {
|
||||
await expect(modal.userDescriptionTextarea).toHaveValue(
|
||||
'Great for close-up portraits and high-frequency details.'
|
||||
)
|
||||
})
|
||||
|
||||
test('typing new description sends debounced metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.userDescriptionTextarea.fill('Updated description body')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.user_description).toBe(
|
||||
'Updated description body'
|
||||
)
|
||||
})
|
||||
|
||||
test('escape key blurs user description textarea', async () => {
|
||||
await modal.userDescriptionTextarea.click()
|
||||
await modal.userDescriptionTextarea.press('Escape')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
modal.userDescriptionTextarea.evaluate(
|
||||
(element) => element === document.activeElement
|
||||
)
|
||||
)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
test('clearing description sends empty-string metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.userDescriptionTextarea.fill('')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBeGreaterThan(initial)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.user_description).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('7) Watchers & State Reset', () => {
|
||||
test('switching assets resets pending metadata updates', async () => {
|
||||
await modal.userDescriptionTextarea.fill('pending draft')
|
||||
|
||||
await focusBareModel()
|
||||
await focusEditableModel()
|
||||
|
||||
await expect(modal.userDescriptionTextarea).toHaveValue(
|
||||
'Great for close-up portraits and high-frequency details.'
|
||||
)
|
||||
})
|
||||
|
||||
test('switching assets resets pending model-type state', async () => {
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
await expect(modal.modelTypeSelect).toContainText(/checkpoints/i)
|
||||
|
||||
await focusImmutableModel()
|
||||
await focusEditableModel()
|
||||
|
||||
await expect(modal.modelTypeSelect).toContainText(/lora/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('8) Debounce Behavior', () => {
|
||||
test('rapid description edits coalesce into one metadata update', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initial = metadataMutations(comfyPage).length
|
||||
|
||||
await modal.userDescriptionTextarea.fill('draft 1')
|
||||
await modal.userDescriptionTextarea.fill('draft 2')
|
||||
await modal.userDescriptionTextarea.fill('final debounced value')
|
||||
|
||||
await expect
|
||||
.poll(() => metadataMutations(comfyPage).length)
|
||||
.toBe(initial + 1)
|
||||
|
||||
const lastBody = getLastMetadataBody(comfyPage)
|
||||
expect(lastBody?.user_metadata?.user_description).toBe(
|
||||
'final debounced value'
|
||||
)
|
||||
})
|
||||
|
||||
test('rapid model type changes coalesce to final debounced mutation set', async () => {
|
||||
const initial = tagCalls.getCalls().length
|
||||
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /checkpoints/i }).click()
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /vae/i }).click()
|
||||
await modal.modelTypeSelect.click()
|
||||
await modal.page.getByRole('option', { name: /lora/i }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => tagCalls.getCalls().length, { timeout: 1200 })
|
||||
.toBe(initial)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,18 +5,16 @@ import {
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Size } from '@e2e/fixtures/types'
|
||||
|
||||
function expectedGroupSize(
|
||||
const expectedGroupSize = (
|
||||
nodeBounds: Size,
|
||||
padding: number,
|
||||
titleHeight: number
|
||||
): Size {
|
||||
return {
|
||||
width: nodeBounds.width + padding * 2,
|
||||
// Group height adds one title row above the contained node bounds (which
|
||||
// themselves already include the node's own title), independent of padding.
|
||||
height: nodeBounds.height + padding * 2 + titleHeight
|
||||
}
|
||||
}
|
||||
): Size => ({
|
||||
width: nodeBounds.width + padding * 2,
|
||||
// Group height adds one title row above the contained node bounds (which
|
||||
// themselves already include the node's own title), independent of padding.
|
||||
height: nodeBounds.height + padding * 2 + titleHeight
|
||||
})
|
||||
|
||||
test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.SnapToGrid.GridSize', () => {
|
||||
@@ -26,7 +24,7 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
async function createNode(comfyPage: ComfyPage) {
|
||||
const createNode = async (comfyPage: ComfyPage) => {
|
||||
const note = await comfyPage.nodeOps.addNode('Note', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
@@ -81,10 +79,10 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
async function groupAroundAllNodesWithPadding(
|
||||
const groupAroundAllNodesWithPadding = async (
|
||||
comfyPage: ComfyPage,
|
||||
padding: number
|
||||
): Promise<Size> {
|
||||
): Promise<Size> => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.GroupSelectedNodes.Padding',
|
||||
padding
|
||||
@@ -128,16 +126,15 @@ test.describe('Canvas layout settings', { tag: '@canvas' }, () => {
|
||||
|
||||
test.describe('LiteGraph.ContextMenu.Scaling', () => {
|
||||
const ZOOM_SCALE = 2
|
||||
function litegraphContextMenu(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.locator('.litecontextmenu')
|
||||
}
|
||||
const litegraphContextMenu = (comfyPage: ComfyPage) =>
|
||||
comfyPage.page.locator('.litecontextmenu')
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.canvasOps.setScale(ZOOM_SCALE)
|
||||
})
|
||||
|
||||
async function openComboMenu(comfyPage: ComfyPage) {
|
||||
const openComboMenu = async (comfyPage: ComfyPage) => {
|
||||
const loadImage = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
|
||||
@@ -3,14 +3,12 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
function getLocators(page: Page) {
|
||||
return {
|
||||
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
|
||||
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
|
||||
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
|
||||
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
|
||||
}
|
||||
}
|
||||
const getLocators = (page: Page) => ({
|
||||
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
|
||||
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
|
||||
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
|
||||
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
|
||||
})
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
const CLIP_NODE_COUNT = 2
|
||||
|
||||
async function getClipNodesDragBox(comfyPage: ComfyPage) {
|
||||
const getClipNodesDragBox = async (comfyPage: ComfyPage) => {
|
||||
const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
clipNodes,
|
||||
@@ -242,11 +242,11 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
* hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local
|
||||
* because it exists only to probe the CanvasPointer timing thresholds.
|
||||
*/
|
||||
async function holdDragAt(
|
||||
const holdDragAt = async (
|
||||
comfyPage: ComfyPage,
|
||||
pos: { x: number; y: number },
|
||||
opts: { dx: number; dy: number; holdMs: number }
|
||||
) {
|
||||
) => {
|
||||
const abs = await comfyPage.canvasOps.toAbsolute(pos)
|
||||
await comfyPage.page.mouse.move(abs.x, abs.y)
|
||||
await comfyPage.page.mouse.down()
|
||||
@@ -383,9 +383,8 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
// (CI jitter, background throttling, canvas-idle behaviour). Assert the
|
||||
// render-loop throttle value instead — that is what actually governs
|
||||
// frame cadence.
|
||||
function getFrameGap(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
|
||||
}
|
||||
const getFrameGap = (comfyPage: ComfyPage) =>
|
||||
comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000)
|
||||
|
||||
test('caps the render loop frame gap', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30)
|
||||
|
||||
@@ -219,7 +219,7 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
async function bypassAndPin() {
|
||||
const bypassAndPin = async () => {
|
||||
await beforeChange(comfyPage)
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect(node).toBeBypassed()
|
||||
@@ -228,14 +228,14 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await afterChange(comfyPage)
|
||||
}
|
||||
|
||||
async function collapse() {
|
||||
const collapse = async () => {
|
||||
await beforeChange(comfyPage)
|
||||
await node.click('collapse', { moveMouseToEmptyArea: true })
|
||||
await expect(node).toBeCollapsed()
|
||||
await afterChange(comfyPage)
|
||||
}
|
||||
|
||||
async function multipleChanges() {
|
||||
const multipleChanges = async () => {
|
||||
await beforeChange(comfyPage)
|
||||
// Call other actions that uses begin/endChange
|
||||
await node.click('title')
|
||||
|
||||
@@ -133,11 +133,10 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
await node.click('title')
|
||||
|
||||
// Normal mode is ALWAYS (0)
|
||||
function getMode() {
|
||||
return comfyPage.page.evaluate((nodeId) => {
|
||||
const getMode = () =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
|
||||
}, node.id)
|
||||
}
|
||||
|
||||
await expect.poll(() => getMode()).toBe(0)
|
||||
|
||||
|
||||
@@ -290,7 +290,7 @@ test('Blueprint overwrite', { tag: ['@subgraph'] }, async ({ comfyPage }) => {
|
||||
|
||||
const confirmDialog = comfyPage.confirmDialog.root
|
||||
const { incrementButton } = comfyPage.vueNodes.getInputNumberControls(steps)
|
||||
async function dirtyGraphAndSave() {
|
||||
const dirtyGraphAndSave = async () => {
|
||||
await incrementButton.click()
|
||||
await comfyPage.page.keyboard.press('Control+s')
|
||||
}
|
||||
|
||||
@@ -21,14 +21,13 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
function getGroupPositions() {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
const getGroupPositions = () =>
|
||||
comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
||||
x: g.pos[0],
|
||||
y: g.pos[1]
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
await expect.poll(getGroupPositions).toHaveLength(2)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Manage group opens with the correct group selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
async function makeGroup(name: string, type1: string, type2: string) {
|
||||
const makeGroup = async (name: string, type1: string, type2: string) => {
|
||||
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
|
||||
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
|
||||
await node1.click('title')
|
||||
@@ -204,7 +204,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test('Reconnects inputs after configuration changed via manage dialog save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
async function expectSingleNode(type: string) {
|
||||
const expectSingleNode = async (type: string) => {
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
|
||||
expect(nodes).toHaveLength(1)
|
||||
return nodes[0]
|
||||
@@ -255,13 +255,13 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
|
||||
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
|
||||
|
||||
async function isRegisteredLitegraph(comfyPage: ComfyPage) {
|
||||
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
|
||||
return await comfyPage.page.evaluate((nodeType: string) => {
|
||||
return !!window.LiteGraph!.registered_node_types[nodeType]
|
||||
}, GROUP_NODE_TYPE)
|
||||
}
|
||||
|
||||
async function isRegisteredNodeDefStore(comfyPage: ComfyPage) {
|
||||
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
|
||||
.getFolder(GROUP_NODE_CATEGORY)
|
||||
@@ -269,10 +269,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
return groupNodesFolderCt === 1
|
||||
}
|
||||
|
||||
async function verifyNodeLoaded(
|
||||
const verifyNodeLoaded = async (
|
||||
comfyPage: ComfyPage,
|
||||
expectedCount: number
|
||||
) {
|
||||
) => {
|
||||
expect(
|
||||
await comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE)
|
||||
).toHaveLength(expectedCount)
|
||||
@@ -361,15 +361,3 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
})
|
||||
|
||||
@@ -510,7 +510,7 @@ test.describe('Image Compare', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
const brokenAfter = 'http://127.0.0.1:1/broken2.png'
|
||||
|
||||
const pageErrors: Error[] = []
|
||||
function onPageError(err: Error) {
|
||||
const onPageError = (err: Error) => {
|
||||
pageErrors.push(err)
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
|
||||
@@ -82,10 +82,10 @@ test.describe('Node Interaction', () => {
|
||||
}
|
||||
)
|
||||
|
||||
async function dragSelectNodes(
|
||||
const dragSelectNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
clipNodes: NodeReference[]
|
||||
) {
|
||||
) => {
|
||||
const clipNode1Pos = await clipNodes[0].getPosition()
|
||||
const clipNode2Pos = await clipNodes[1].getPosition()
|
||||
const offset = 64
|
||||
@@ -117,16 +117,15 @@ test.describe('Node Interaction', () => {
|
||||
}) => {
|
||||
const clipNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
function getPositions() {
|
||||
return Promise.all(clipNodes.map((node) => node.getPosition()))
|
||||
}
|
||||
async function testDirection({
|
||||
const getPositions = () =>
|
||||
Promise.all(clipNodes.map((node) => node.getPosition()))
|
||||
const testDirection = async ({
|
||||
direction,
|
||||
expectedPosition
|
||||
}: {
|
||||
direction: string
|
||||
expectedPosition: (originalPosition: Position) => Position
|
||||
}) {
|
||||
}) => {
|
||||
const originalPositions = await getPositions()
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
await comfyPage.command.executeCommand(
|
||||
@@ -672,7 +671,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
})
|
||||
|
||||
test('Cursor style changes when panning', async ({ comfyPage }) => {
|
||||
async function getCursorStyle() {
|
||||
const getCursorStyle = async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
return (
|
||||
document.getElementById('graph-canvas')!.style.cursor || 'default'
|
||||
@@ -704,7 +703,7 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Properly resets dragging state after pan mode sequence', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
async function getCursorStyle() {
|
||||
const getCursorStyle = async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
return (
|
||||
document.getElementById('graph-canvas')!.style.cursor || 'default'
|
||||
@@ -879,9 +878,8 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
function generateUniqueFilename(extension = '') {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
}
|
||||
const generateUniqueFilename = (extension = '') =>
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
|
||||
|
||||
test.describe('Restore all open workflows on reload', () => {
|
||||
let workflowA: string
|
||||
@@ -1079,7 +1077,7 @@ test.describe('Viewport settings', () => {
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
async function changeTab(tab: Locator) {
|
||||
const changeTab = async (tab: Locator) => {
|
||||
await tab.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyMouse.move(DefaultGraphPositions.emptySpace)
|
||||
@@ -1408,7 +1406,7 @@ test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
test('Cursor changes appropriately in different modes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
async function getCursorStyle() {
|
||||
const getCursorStyle = async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
return (
|
||||
document.getElementById('graph-canvas')!.style.cursor || 'default'
|
||||
|
||||
@@ -3,15 +3,14 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
function getGizmoConfig(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const getGizmoConfig = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
const modelConfig = n?.properties?.['Model Config'] as
|
||||
| { gizmo?: { enabled: boolean; mode: string } }
|
||||
| undefined
|
||||
return modelConfig?.gizmo
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Load3D Gizmo Controls', () => {
|
||||
test(
|
||||
|
||||
@@ -155,7 +155,7 @@ test.describe('Load3D', () => {
|
||||
async ({ comfyPage, load3d }) => {
|
||||
await expect(load3d.uploadBackgroundImageButton).toBeVisible()
|
||||
const node = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
async function readBackgroundImage() {
|
||||
const readBackgroundImage = async () => {
|
||||
const properties =
|
||||
await node.getProperty<Record<string, { backgroundImage?: string }>>(
|
||||
'properties'
|
||||
@@ -222,7 +222,7 @@ test.describe('Load3D', () => {
|
||||
await expect(load3d.gridToggleButton).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById(1)
|
||||
async function readShowGrid() {
|
||||
const readShowGrid = async () => {
|
||||
const properties =
|
||||
await node.getProperty<Record<string, { showGrid?: boolean }>>(
|
||||
'properties'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,65 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.describe(
|
||||
'Node context menu shape submenu (FE-570)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
})
|
||||
|
||||
async function expectShapePopoverVisible(comfyPage: ComfyPage) {
|
||||
const popover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover).toContainText('Box')
|
||||
await expect(popover).toContainText('Card')
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(popoverBox!.width).toBeGreaterThan(0)
|
||||
expect(popoverBox!.height).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('Shape popover opens when the menu fits in the viewport', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 900 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() => rootList.evaluate((el) => getComputedStyle(el).overflowY))
|
||||
.toBe('visible')
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Shape' }).click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
|
||||
test('Shape popover opens even when the menu must scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
rootList.evaluate((el) => el.scrollHeight > el.clientHeight)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const shapeItem = menu.getByRole('menuitem', { name: 'Shape' })
|
||||
await shapeItem.scrollIntoViewIfNeeded()
|
||||
await shapeItem.click()
|
||||
await expectShapePopoverVisible(comfyPage)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -55,7 +55,7 @@ async function setLocaleAndWaitForWorkflowReload(
|
||||
const waitForReload = new Promise<void>((resolve, reject) => {
|
||||
const timeoutAt = performance.now() + 5000
|
||||
|
||||
function tick() {
|
||||
const tick = () => {
|
||||
if (changeTracker.isLoadingGraph) {
|
||||
sawLoading = true
|
||||
}
|
||||
|
||||
@@ -166,10 +166,10 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test.describe('Filtering', () => {
|
||||
async function expectFilterChips(
|
||||
const expectFilterChips = async (
|
||||
comfyPage: ComfyPage,
|
||||
expectedTexts: string[]
|
||||
) {
|
||||
) => {
|
||||
const chips = comfyPage.searchBox.filterChips
|
||||
|
||||
// Check that the number of chips matches the expected count
|
||||
|
||||
@@ -243,18 +243,15 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
function switchToDesktop() {
|
||||
return comfyPage.page.setViewportSize({ width: 1280, height: 800 })
|
||||
}
|
||||
function switchToMobile() {
|
||||
return comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
}
|
||||
function expectExpanded(value: 'true' | 'false') {
|
||||
return expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
const switchToDesktop = () =>
|
||||
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
|
||||
const switchToMobile = () =>
|
||||
comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
value
|
||||
)
|
||||
}
|
||||
|
||||
await switchToDesktop()
|
||||
await searchBoxV2.open()
|
||||
|
||||
@@ -312,9 +312,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
test.describe('Search behavior', () => {
|
||||
test('Search narrows results progressively', async ({ comfyPage }) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
function getCount() {
|
||||
return searchBoxV2.results.count()
|
||||
}
|
||||
const getCount = () => searchBoxV2.results.count()
|
||||
|
||||
await searchBoxV2.open()
|
||||
|
||||
|
||||
@@ -758,8 +758,8 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
function hasContentAtRow(yFraction: number) {
|
||||
return canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
||||
const hasContentAtRow = (yFraction: number) =>
|
||||
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cy = Math.floor(el.height * y)
|
||||
@@ -769,7 +769,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
}
|
||||
return false
|
||||
}, yFraction)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => hasContentAtRow(0.25), {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName)
|
||||
await expect(comfyPage.searchBox.input).toBeHidden()
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
@@ -77,7 +77,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
|
||||
}
|
||||
cloudUploadAssetStateByPage.set(page, state)
|
||||
|
||||
async function assetsRouteHandler(route: Route) {
|
||||
const assetsRouteHandler = async (route: Route) => {
|
||||
const allAssets = [
|
||||
cloudDefaultGraphInputAsset,
|
||||
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
|
||||
@@ -149,7 +149,7 @@ async function delayNextUpload(comfyPage: ComfyPage) {
|
||||
releaseUpload = resolve
|
||||
})
|
||||
|
||||
async function uploadRouteHandler(route: Route) {
|
||||
const uploadRouteHandler = async (route: Route) => {
|
||||
resolveUploadStarted()
|
||||
await release
|
||||
await route.continue()
|
||||
|
||||
@@ -15,9 +15,7 @@ const REQUEST_ID_SECONDARY = 2
|
||||
const REQUEST_ID_MISMATCH = 999
|
||||
|
||||
let nextRequestId = 1000
|
||||
function newRequestId() {
|
||||
return nextRequestId++
|
||||
}
|
||||
const newRequestId = () => nextRequestId++
|
||||
|
||||
function bannerLocator(page: Page) {
|
||||
return page.getByTestId(TestIds.queue.notificationBanner)
|
||||
|
||||
@@ -6,11 +6,11 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
|
||||
const mockOptions = ['d', 'c', 'b', 'a']
|
||||
|
||||
async function addRemoteWidgetNode(
|
||||
const addRemoteWidgetNode = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeName: string,
|
||||
count: number = 1
|
||||
) {
|
||||
) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolder('DevTools').click()
|
||||
@@ -21,24 +21,24 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function getWidgetOptions(
|
||||
const getWidgetOptions = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeName: string
|
||||
): Promise<string[] | undefined> {
|
||||
): Promise<string[] | undefined> => {
|
||||
return await comfyPage.page.evaluate((name) => {
|
||||
const node = window.app!.graph!.nodes.find((node) => node.title === name)
|
||||
return node!.widgets![0].options.values as string[] | undefined
|
||||
}, nodeName)
|
||||
}
|
||||
|
||||
async function getWidgetValue(comfyPage: ComfyPage, nodeName: string) {
|
||||
const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => {
|
||||
return await comfyPage.page.evaluate((name) => {
|
||||
const node = window.app!.graph!.nodes.find((node) => node.title === name)
|
||||
return node!.widgets![0].value
|
||||
}, nodeName)
|
||||
}
|
||||
|
||||
function clickRefreshButton(comfyPage: ComfyPage, nodeName: string) {
|
||||
const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => {
|
||||
return comfyPage.page.evaluate((name) => {
|
||||
const node = window.app!.graph!.nodes.find((node) => node.title === name)
|
||||
const buttonWidget = node!.widgets!.find((w) => w.name === 'refresh')
|
||||
|
||||
@@ -13,21 +13,16 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
function getColorPickerButton(comfyPage: { page: Page }) {
|
||||
return comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
}
|
||||
const getColorPickerButton = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerButton)
|
||||
|
||||
function getColorPickerCurrentColor(comfyPage: { page: Page }) {
|
||||
return comfyPage.page.getByTestId(
|
||||
TestIds.selectionToolbox.colorPickerCurrentColor
|
||||
)
|
||||
}
|
||||
const getColorPickerCurrentColor = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.colorPickerCurrentColor)
|
||||
|
||||
function getColorPickerGroup(comfyPage: { page: Page }) {
|
||||
return comfyPage.page.getByRole('group').filter({
|
||||
const getColorPickerGroup = (comfyPage: { page: Page }) =>
|
||||
comfyPage.page.getByRole('group').filter({
|
||||
has: comfyPage.page.getByTestId(TestIds.selectionToolbox.colorBlue)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { openMoreOptionsMenu } from '@e2e/fixtures/utils/selectionToolboxMoreOptions'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -19,8 +18,60 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
function openMoreOptions(comfyPage: ComfyPage) {
|
||||
return openMoreOptionsMenu(comfyPage, 'KSampler')
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) {
|
||||
throw new Error(
|
||||
'Viewport size is null - page may not be properly initialized'
|
||||
)
|
||||
}
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('hides Node Info from More Options menu when the new menu is disabled', async ({
|
||||
@@ -41,14 +92,11 @@ test.describe(
|
||||
)[0]
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
// Shape now opens via body-appended popover (FE-570); a hover no
|
||||
// longer reveals the submenu — match the Color flow and click.
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).click()
|
||||
const shapePopover = comfyPage.page
|
||||
.locator('.p-popover')
|
||||
.filter({ hasText: 'Default' })
|
||||
await expect(shapePopover.getByText('Box', { exact: true })).toBeVisible()
|
||||
await shapePopover.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible()
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.getProperty<number>('shape')).toBe(1)
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
sharedWorkflowImportFixture,
|
||||
sharedWorkflowImportScenario
|
||||
} from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import type { SharedWorkflowImportMocks } from '@e2e/fixtures/sharedWorkflowImportFixture'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
|
||||
const IMPORT_ORDER_TIMEOUT_MS = 5_000
|
||||
|
||||
async function expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await expect(async () => {
|
||||
const events = mocks.getRequestEvents()
|
||||
const importIndex = events.indexOf('import')
|
||||
const afterImportIndex = events.indexOf(
|
||||
'input-assets-including-public-after-import'
|
||||
)
|
||||
|
||||
expect(
|
||||
events,
|
||||
'public-inclusive input assets must not be scanned before import'
|
||||
).not.toContain('input-assets-including-public-before-import')
|
||||
expect(importIndex, `events: ${events.join(',')}`).toBeGreaterThanOrEqual(0)
|
||||
expect(afterImportIndex, `events: ${events.join(',')}`).toBeGreaterThan(
|
||||
importIndex
|
||||
)
|
||||
}).toPass({ timeout: IMPORT_ORDER_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage: ComfyPage,
|
||||
mocks: SharedWorkflowImportMocks
|
||||
): Promise<void> {
|
||||
await mocks.waitForPublicInclusiveInputAssetResponseAfterImport()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getCachedMissingMediaWarningNames(comfyPage))
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
async function openPanelAndExpectNoMissingMedia(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const page = comfyPage.page
|
||||
const errorOverlay = page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const panel = new PropertiesPanelHelper(page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(
|
||||
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
await expect(page.getByTestId(TestIds.dialogs.missingMediaGroup)).toHaveCount(
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const test = mergeTests(comfyPageFixture, sharedWorkflowImportFixture)
|
||||
|
||||
test.describe('Shared workflow missing media', { tag: '@cloud' }, () => {
|
||||
// Missing media only surfaces the overlay when the Errors tab is enabled
|
||||
// (src/stores/executionErrorStore.ts).
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ comfyPage, sharedWorkflowImportMocks }) => {
|
||||
sharedWorkflowImportMocks.resetAndStartRecording()
|
||||
await comfyPage.setup({
|
||||
clearStorage: false,
|
||||
url: `/?share=${sharedWorkflowImportScenario.shareId}`
|
||||
})
|
||||
})
|
||||
|
||||
test('imports shared media before loading workflow so missing media is not surfaced', async ({
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
await dialog.getByTestId(TestIds.dialogs.openSharedWorkflowConfirm).click()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((node) => ({
|
||||
type: node.type,
|
||||
value: node.widgets?.[0]?.value
|
||||
}))
|
||||
)
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
value: sharedWorkflowImportScenario.inputFileName
|
||||
}
|
||||
])
|
||||
await expectImportPrecedesPublicInclusiveInputAssetScan(
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
await expectNoMissingMediaAfterPublicInclusiveAssetScan(
|
||||
comfyPage,
|
||||
sharedWorkflowImportMocks
|
||||
)
|
||||
|
||||
expect(sharedWorkflowImportMocks.getImportBody()).toEqual({
|
||||
published_asset_ids: [sharedWorkflowImportScenario.publishedAssetId],
|
||||
share_id: sharedWorkflowImportScenario.shareId
|
||||
})
|
||||
expect(new URL(page.url()).searchParams.has('share')).toBe(false)
|
||||
await openPanelAndExpectNoMissingMedia(comfyPage)
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
jobsRouteFixture,
|
||||
routeMockJobTimestamp
|
||||
} from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsScenario } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import type { JobsRouteMocker } from '@e2e/fixtures/jobsRouteFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
|
||||
const historyJobs: RawJobListItem[] = [
|
||||
createRouteMockJob({
|
||||
id: 'history-completed',
|
||||
@@ -73,24 +75,21 @@ const activeJobs: RawJobListItem[] = [
|
||||
]
|
||||
const runningOnlyJobs = activeJobs.filter((job) => job.status !== 'pending')
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsRouteFixture).extend<{
|
||||
initialJobsScenario: JobsScenario
|
||||
mockInitialJobsScenario: void
|
||||
}>({
|
||||
initialJobsScenario: [
|
||||
{ history: historyJobs, queue: activeJobs },
|
||||
{ option: true }
|
||||
],
|
||||
mockInitialJobsScenario: [
|
||||
async ({ jobsRoutes, initialJobsScenario }, use) => {
|
||||
await jobsRoutes.mockJobsScenario(initialJobsScenario)
|
||||
await use()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
async function setupJobHistorySidebar(
|
||||
comfyPage: ComfyPage,
|
||||
jobsRoutes: JobsRouteMocker,
|
||||
{
|
||||
history = historyJobs,
|
||||
queue = activeJobs
|
||||
}: {
|
||||
history?: readonly RawJobListItem[]
|
||||
queue?: readonly RawJobListItem[]
|
||||
} = {}
|
||||
) {
|
||||
await jobsRoutes.mockJobsScenario({ history, queue })
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.setup()
|
||||
|
||||
async function openJobHistorySidebar(comfyPage: ComfyPage) {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.sidebar.toolbar)
|
||||
.getByRole('button', { name: 'Job History', exact: true })
|
||||
@@ -123,152 +122,159 @@ async function openSidebarClearHistoryDialog(comfyPage: ComfyPage) {
|
||||
}
|
||||
|
||||
test.describe('Job history sidebar', { tag: '@ui' }, () => {
|
||||
test.describe('docked overlay action', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': false } })
|
||||
|
||||
test('opens from the queue overlay docked history action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
await comfyPage.queuePanel.moreOptionsButton.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.queue.dockedJobHistoryAction)
|
||||
.click()
|
||||
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
|
||||
test('opens from the queue overlay docked history action', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: activeJobs
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
await comfyPage.queuePanel.moreOptionsButton.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.queue.dockedJobHistoryAction)
|
||||
.click()
|
||||
|
||||
await expect(jobHistorySidebar(comfyPage)).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('history-completed')).toBeVisible()
|
||||
await expect(jobRow(comfyPage)('queue-pending')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('expanded history tab', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Queue.QPOV2': true } })
|
||||
test('shows terminal and active job states', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
test('shows terminal and active job states', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
|
||||
test('filters completed and failed history jobs', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Failed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
})
|
||||
|
||||
test('searches by job id and output filename', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search...')
|
||||
|
||||
await searchInput.fill('history-failed')
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await searchInput.fill('completed-output')
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
|
||||
await searchInput.clear()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
await clearQueueButton(comfyPage).click()
|
||||
|
||||
await expect.poll(() => clearQueueRequests.length).toBe(1)
|
||||
expect(clearQueueRequests).toContainEqual({ clear: true })
|
||||
await expect(row('queue-pending')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
expect(clearHistoryRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('clears history from the sidebar menu and keeps active jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: [],
|
||||
queue: activeJobs
|
||||
})
|
||||
|
||||
await openSidebarClearHistoryDialog(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => clearHistoryRequests.length).toBe(1)
|
||||
expect(clearHistoryRequests).toContainEqual({ clear: true })
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
expect(clearQueueRequests).toHaveLength(0)
|
||||
})
|
||||
await expect(clearQueueButton(comfyPage)).toBeEnabled()
|
||||
})
|
||||
|
||||
test.describe('without pending queue jobs', () => {
|
||||
test.use({
|
||||
initialJobsScenario: { history: historyJobs, queue: runningOnlyJobs },
|
||||
initialSettings: { 'Comfy.Queue.QPOV2': true }
|
||||
test('filters completed and failed history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Completed', exact: true })
|
||||
.click()
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Failed', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-cancelled')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
})
|
||||
|
||||
test('searches by job id and output filename', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
const searchInput = comfyPage.page.getByPlaceholder('Search...')
|
||||
|
||||
await searchInput.fill('history-failed')
|
||||
await expect(row('history-failed')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeHidden()
|
||||
|
||||
await searchInput.fill('completed-output')
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
|
||||
await searchInput.clear()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
})
|
||||
|
||||
test('disables clear queue when there are no pending jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes, {
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
test('disables clear queue', async ({ comfyPage }) => {
|
||||
await openJobHistorySidebar(comfyPage)
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
})
|
||||
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
test('clears pending queue jobs and leaves running/history jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: historyJobs,
|
||||
queue: runningOnlyJobs
|
||||
})
|
||||
|
||||
await clearQueueButton(comfyPage).click()
|
||||
|
||||
await expect.poll(() => clearQueueRequests.length).toBe(1)
|
||||
expect(clearQueueRequests).toContainEqual({ clear: true })
|
||||
await expect(row('queue-pending')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
await expect(clearQueueButton(comfyPage)).toBeDisabled()
|
||||
expect(clearHistoryRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('clears history from the sidebar menu and keeps active jobs', async ({
|
||||
comfyPage,
|
||||
jobsRoutes
|
||||
}) => {
|
||||
await setupJobHistorySidebar(comfyPage, jobsRoutes)
|
||||
|
||||
const row = jobRow(comfyPage)
|
||||
await expect(row('history-completed')).toBeVisible()
|
||||
|
||||
const clearHistoryRequests = await jobsRoutes.mockClearHistory()
|
||||
const clearQueueRequests = await jobsRoutes.mockClearQueue()
|
||||
await jobsRoutes.mockJobsScenario({
|
||||
history: [],
|
||||
queue: activeJobs
|
||||
})
|
||||
|
||||
await openSidebarClearHistoryDialog(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Clear your job queue history?')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.getByRole('button', { name: 'Clear', exact: true })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => clearHistoryRequests.length).toBe(1)
|
||||
expect(clearHistoryRequests).toContainEqual({ clear: true })
|
||||
await expect(row('history-completed')).toBeHidden()
|
||||
await expect(row('history-failed')).toBeHidden()
|
||||
await expect(row('queue-running')).toBeVisible()
|
||||
await expect(row('queue-pending')).toBeVisible()
|
||||
expect(clearQueueRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,12 +114,11 @@ test.describe('Sidebar splitter width independence', () => {
|
||||
await dragGutter(comfyPage, 80)
|
||||
|
||||
// Check that saved sizes sum to ~100%
|
||||
function getSidebarSizes() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const getSidebarSizes = () =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const raw = localStorage.getItem('unified-sidebar')
|
||||
return raw ? (JSON.parse(raw) as number[]) : null
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ const MISSING_NODES_SUBGRAPH_NODE_ID = '2'
|
||||
* the root graph, then the inner subgraph node that appears inside. Matches
|
||||
* how a user navigates via the canvas.
|
||||
*/
|
||||
async function enterNestedSubgraphs(comfyPage: ComfyPage) {
|
||||
const enterNestedSubgraphs = async (comfyPage: ComfyPage) => {
|
||||
const outerNode = await comfyPage.nodeOps.getNodeRefById(
|
||||
OUTER_SUBGRAPH_NODE_ID_IN_NESTED
|
||||
)
|
||||
|
||||
@@ -689,9 +689,7 @@ test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
function isConnected() {
|
||||
return comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
}
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
@@ -737,9 +735,7 @@ test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
function isConnected() {
|
||||
return comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
}
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
@@ -816,9 +812,7 @@ test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
function isConnected() {
|
||||
return comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
}
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
@@ -368,16 +368,15 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
]
|
||||
|
||||
const SENTINEL_IDS = new Set([-1, -10, -20])
|
||||
function isSentinelNodeId(id: number | string): id is number {
|
||||
return typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
}
|
||||
const isSentinelNodeId = (id: number | string): id is number =>
|
||||
typeof id === 'number' && SENTINEL_IDS.has(id)
|
||||
|
||||
function checkEndpoint(
|
||||
const checkEndpoint = (
|
||||
label: string,
|
||||
kind: 'origin_id' | 'target_id',
|
||||
id: number | string,
|
||||
g: typeof graph
|
||||
): string | null {
|
||||
): string | null => {
|
||||
if (isSentinelNodeId(id)) return null
|
||||
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
||||
return `${label}: ${kind} ${id} invalid or not found`
|
||||
|
||||
@@ -632,75 +632,3 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'link interactions',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const seedSlot = ksampler.getSlot('seed')
|
||||
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
|
||||
|
||||
await test.step('Make second INT typed connection', async () => {
|
||||
const toPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
function isConnected() {
|
||||
return comfyPage.vueNodes.isSlotConnected(seedSlot)
|
||||
}
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
await stepsSlot.hover()
|
||||
await stepsSlot.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
|
||||
//cancel link operation
|
||||
await stepsSlot.hover()
|
||||
await comfyPage.page.mouse.up()
|
||||
})
|
||||
|
||||
await ksampler.title.hover()
|
||||
|
||||
const slotParent = stepsSlot.locator('../..')
|
||||
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Connect I/O to node with snap', async () => {
|
||||
function hasSnap() {
|
||||
return comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
|
||||
}
|
||||
expect(await hasSnap()).toBe(false)
|
||||
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
//move hover off the slot
|
||||
await ksampler.title.hover()
|
||||
})
|
||||
|
||||
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -14,7 +14,7 @@ test.describe(
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
async function assertInSubgraph(inSubgraph: boolean) {
|
||||
const assertInSubgraph = async (inSubgraph: boolean) => {
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph())
|
||||
.toBe(inSubgraph)
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
@@ -7,9 +7,9 @@ test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
|
||||
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
|
||||
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
|
||||
|
||||
function createMockSystemStatsRes(
|
||||
const createMockSystemStatsRes = (
|
||||
requiredFrontendVersion: string
|
||||
): SystemStats {
|
||||
): SystemStats => {
|
||||
return {
|
||||
system: {
|
||||
os: 'posix',
|
||||
|
||||
@@ -79,7 +79,7 @@ async function getNodeGroupCenteringErrors(
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect()
|
||||
|
||||
function getCenteringError(group: GraphGroup): NodeGroupCenteringError {
|
||||
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
|
||||
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
|
||||
group.pos[0],
|
||||
group.pos[1]
|
||||
|
||||
@@ -1151,14 +1151,12 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
|
||||
const ksampler = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
|
||||
if (!node) return null
|
||||
const ksamplerNode = node
|
||||
function findIndex(name: string) {
|
||||
return ksamplerNode.inputs.findIndex(
|
||||
const findIndex = (name: string) =>
|
||||
node.inputs.findIndex(
|
||||
(input) => input.name === name || input.widget?.name === name
|
||||
)
|
||||
}
|
||||
return {
|
||||
id: ksamplerNode.id,
|
||||
id: node.id,
|
||||
denoiseIndex: findIndex('denoise'),
|
||||
schedulerIndex: findIndex('scheduler')
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
async function getHeaderPos(
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
@@ -21,30 +21,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
return box
|
||||
}
|
||||
|
||||
async function getLoadCheckpointHeaderPos(comfyPage: ComfyPage) {
|
||||
return getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
}
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
async function expectPosChanged(pos1: Position, pos2: Position) {
|
||||
const expectPosChanged = async (pos1: Position, pos2: Position) => {
|
||||
const diffX = Math.abs(pos2.x - pos1.x)
|
||||
const diffY = Math.abs(pos2.y - pos1.y)
|
||||
expect(diffX).toBeGreaterThan(0)
|
||||
expect(diffY).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
function deltaBetween(before: Position, after: Position) {
|
||||
return {
|
||||
x: after.x - before.x,
|
||||
y: after.y - before.y
|
||||
}
|
||||
}
|
||||
const deltaBetween = (before: Position, after: Position) => ({
|
||||
x: after.x - before.x,
|
||||
y: after.y - before.y
|
||||
})
|
||||
|
||||
function expectSameDelta(a: Position, b: Position, tol = 2) {
|
||||
const expectSameDelta = (a: Position, b: Position, tol = 2) => {
|
||||
expect(Math.abs(a.x - b.x)).toBeLessThanOrEqual(tol)
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
async function dragFromTabButton(comfyPage: ComfyPage, button: Locator) {
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
@@ -175,7 +172,7 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const dx = 120
|
||||
const dy = 80
|
||||
|
||||
async function clickNodeTitleWithMeta(title: string) {
|
||||
const clickNodeTitleWithMeta = async (title: string) => {
|
||||
await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
|
||||
@@ -15,11 +15,10 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
|
||||
})
|
||||
|
||||
function getWidth() {
|
||||
return comfyPage.page.evaluate(
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
|
||||
|
||||
@@ -5,15 +5,11 @@ import {
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Multiline String Widget', { tag: '@vue-nodes' }, () => {
|
||||
function getFirstClipNode(comfyPage: ComfyPage) {
|
||||
return comfyPage.vueNodes
|
||||
.getNodeByTitle('CLIP Text Encode (Prompt)')
|
||||
.first()
|
||||
}
|
||||
const getFirstClipNode = (comfyPage: ComfyPage) =>
|
||||
comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode (Prompt)').first()
|
||||
|
||||
function getFirstMultilineStringWidget(comfyPage: ComfyPage) {
|
||||
return getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
|
||||
}
|
||||
const getFirstMultilineStringWidget = (comfyPage: ComfyPage) =>
|
||||
getFirstClipNode(comfyPage).getByRole('textbox', { name: 'text' })
|
||||
|
||||
test('should allow entering text', async ({ comfyPage }) => {
|
||||
const textarea = getFirstMultilineStringWidget(comfyPage)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user