diff --git a/POSTHOG_TELEMETRY_EVENTS.md b/POSTHOG_TELEMETRY_EVENTS.md new file mode 100644 index 000000000..ff0305fe2 --- /dev/null +++ b/POSTHOG_TELEMETRY_EVENTS.md @@ -0,0 +1,254 @@ +# PostHog telemetry events + +Repository inventory based on commit `379dc684`. + +> [!NOTE] +> Unpackaged development builds (`pnpm dev`) do not send PostHog writes. The +> telemetry initializer sets `suppressEmit = true` when `isPackaged` is false. + +PostHog writes are centralized in `src/main/lib/telemetry.ts`. Renderer events +are forwarded to it through `src/main/lib/ipc/registerTelemetryHandlers.ts`. + +## Fixed event names + +### PostHog-native + +- `$exception` +- `$identify` + +`comfy.desktop.exception.error` is an internal rate-limit and Datadog name; +PostHog receives `$exception`. + +### Authentication and identity + +- `app:user_logged_in` +- `comfy.desktop.auth.sign_in_started` +- `comfy.desktop.auth.sign_in_failed` +- `comfy.desktop.identity.login_attributed` +- `comfy.desktop.identity.pending_consensus_expired` +- `comfy.desktop.person.set` + +### Application and sessions + +- `comfy.desktop.app.first_launch` +- `comfy.desktop.app.language_resolved` +- `comfy.desktop.session.started` +- `comfy.desktop.session.ended` +- `comfy.desktop.session.instance_started` +- `comfy.desktop.session.installation_started` +- `comfy.desktop.session.installs_inventory` +- `comfy.desktop.session.snapshot_history` +- `comfy.desktop.session.storage_detected` +- `comfy.desktop.session.system_info` + +### App updates + +- `comfy.desktop.app_update.available` +- `comfy.desktop.app_update.checked` +- `comfy.desktop.app_update.download_started` +- `comfy.desktop.app_update.download_complete` +- `comfy.desktop.app_update.error` +- `comfy.desktop.app_update.ignored_not_newer` +- `comfy.desktop.app_update.install_triggered` +- `comfy.desktop.app_update.startup_install` +- `comfy.desktop.app_update.startup_install_skipped` +- `comfy.desktop.app_update.startup_install_backstop_recovered` + +### First-use funnel + +- `comfy.desktop.first_use.abandoned` +- `comfy.desktop.first_use.completed` +- `comfy.desktop.first_use.consent_decision` +- `comfy.desktop.first_use.fork_chosen` +- `comfy.desktop.first_use.local_branch_chosen` +- `comfy.desktop.first_use.mirrors_chosen` +- `comfy.desktop.first_use.step_viewed` +- `comfy.desktop.first_use.why_cloud_opened` +- `comfy.desktop.first_use.why_cloud_action` + +### Installation + +- `comfy.desktop.install.completed` +- `comfy.desktop.install.disk_warning.response` +- `comfy.desktop.install.dispatched` +- `comfy.desktop.install.express.started` +- `comfy.desktop.install.express.fallback` +- `comfy.desktop.install.flow.opened` +- `comfy.desktop.install.guardrail.blocked` +- `comfy.desktop.install.method.selected` +- `comfy.desktop.install.not_started` +- `comfy.desktop.install.phase` +- `comfy.desktop.install.showcase.cloud_open` +- `comfy.desktop.install.validation` +- `comfy.desktop.install.variant.selected` + +### ComfyUI lifecycle + +- `comfy.desktop.comfyui.accelerator_detected` +- `comfy.desktop.comfyui.boot_started` +- `comfy.desktop.comfyui.boot_completed` +- `comfy.desktop.comfyui.boot_failed` +- `comfy.desktop.comfyui.boot_log` +- `comfy.desktop.comfyui.boot_phase` +- `comfy.desktop.comfyui.canvas_rendered` +- `comfy.desktop.comfyui.exited` +- `comfy.desktop.comfyui.model_usage_summary` +- `comfy.desktop.comfyui.update.applied` + +### Instances, actions, and navigation + +- `comfy.desktop.action.invoked` +- `comfy.desktop.action.result` +- `comfy.desktop.op.result` +- `comfy.desktop.instance.opened_new_window` +- `comfy.desktop.instance.relaunched_after_crash` +- `comfy.desktop.instance.return_to_dashboard` +- `comfy.desktop.instance.switched` +- `comfy.desktop.view.opened` +- `comfy.desktop.workspace.refresh` +- `comfy.desktop.zoom.reset` +- `comfy.desktop.title_menu.item_clicked` + +### Execution and models + +- `comfy.desktop.execution.error` +- `comfy.desktop.execution.first_completed` +- `comfy.desktop.execution.session_summary` +- `comfy.desktop.model_download.started` +- `comfy.desktop.model_download.result` +- `comfy.desktop.node.installed` + +### Snapshots and migration + +- `comfy.desktop.snapshot.created` +- `comfy.desktop.snapshot.flow` +- `comfy.desktop.snapshot.imported` +- `comfy.desktop.snapshot.shared` +- `comfy.desktop.migrate.restore_snapshot.error` +- `comfy.desktop.recovery.failed` +- `comfy.desktop.recovery.rolled_back` +- `comfy.desktop.track_existing.saved` + +### Templates + +- `comfy.desktop.template.download.skipped` +- `comfy.desktop.template.install_confirmed` +- `comfy.desktop.template.picker_shown` +- `comfy.desktop.template.selected` +- `comfy.desktop.template.skipped` + +### Settings + +- `comfy.desktop.args.builder.opened` +- `comfy.desktop.args.changed` +- `comfy.desktop.settings.changed` + +### Billing and cloud + +- `comfy.desktop.billing.checkout_opened` +- `comfy.desktop.billing.checkout_returned` +- `comfy.desktop.billing.tier_changed` +- `comfy.desktop.cloud.entered` +- `comfy.desktop.dashboard.why_cloud_opened` +- `comfy.desktop.dashboard.why_cloud_action` + +### Feedback and experiments + +- `comfy.desktop.feedback.opened` +- `comfy.desktop.feedback.submitted` +- `comfy.desktop.experiment.exposed` + +### Git and environment repair + +- `comfy.desktop.git.system_fallback` +- `comfy.desktop.pygit2.circuit_broken` +- `comfy.desktop.pygit2.probe_failed` +- `comfy.desktop.pygit2.repair_attempted` +- `comfy.desktop.manager.config_seed_failed` +- `comfy.desktop.torch_repair.detected` +- `comfy.desktop.torch_repair.failed` +- `comfy.desktop.torch_repair.recovery_failed` +- `comfy.desktop.torch_repair.succeeded` + +### Desktop adoption + +- `comfy.desktop.adopt.started` +- `comfy.desktop.adopt.succeeded` + +### MCP + +- `comfy.desktop.mcp.sidebar_opened` +- `comfy.desktop.mcp.docs_opened` +- `comfy.desktop.mcp.option_selected` +- `comfy.desktop.mcp.panel_dismissed` +- `comfy.desktop.mcp.path_selected` +- `comfy.desktop.mcp.snippet_copied` +- `comfy.desktop.mcp.terminal_opened` + +### Telemetry safeguards + +- `comfy.desktop.telemetry.rate_limited` +- `comfy.desktop.telemetry.session_cap_hit` + +## Generated step events + +`trackedStep()` in `src/main/lib/telemetry.ts` generates `.start` and `.end` +events for every base below. Therefore `x.{start,end}` represents two exact +event names: `x.start` and `x.end`. + +### Adoption + +- `comfy.desktop.adopt.allocate.{start,end}` +- `comfy.desktop.adopt.backup.{start,end}` +- `comfy.desktop.adopt.carry_settings.{start,end}` +- `comfy.desktop.adopt.detect.{start,end}` +- `comfy.desktop.adopt.find_existing.{start,end}` +- `comfy.desktop.adopt.register.{start,end}` +- `comfy.desktop.adopt.requirements.{start,end}` +- `comfy.desktop.adopt.requirements_reconcile.{start,end}` +- `comfy.desktop.adopt.snapshot.{start,end}` +- `comfy.desktop.adopt.source.{start,end}` +- `comfy.desktop.adopt.tcc.{start,end}` +- `comfy.desktop.adopt.validate_venv.{start,end}` + +### Installation and migration + +- `comfy.desktop.install.post_install.{start,end}` +- `comfy.desktop.install.standalone.{start,end}` +- `comfy.desktop.migrate.allocate.{start,end}` +- `comfy.desktop.migrate.finalize.{start,end}` +- `comfy.desktop.migrate.flow.{start,end}` +- `comfy.desktop.migrate.input.{start,end}` +- `comfy.desktop.migrate.models.{start,end}` +- `comfy.desktop.migrate.output.{start,end}` +- `comfy.desktop.migrate.prepare_target.{start,end}` +- `comfy.desktop.migrate.resolve_target.{start,end}` +- `comfy.desktop.migrate.restore_snapshot.{start,end}` +- `comfy.desktop.migrate.source_preflight.{start,end}` +- `comfy.desktop.migrate.user_files.{start,end}` + +### Snapshot restoration + +- `comfy.desktop.snapshot.restore_comfyui_version.{start,end}` +- `comfy.desktop.snapshot.restore_custom_nodes.{start,end}` +- `comfy.desktop.snapshot.restore_pip_packages.{start,end}` + +### Reachable generated errors + +Canonical flow scopes suppress duplicate inner errors. The current production +call graph can emit these generated failures: + +- `comfy.desktop.adopt.requirements_reconcile.error` +- `comfy.desktop.migrate.flow.error` +- `comfy.desktop.migrate.restore_snapshot.error` +- `comfy.desktop.snapshot.restore_comfyui_version.error` +- `comfy.desktop.snapshot.restore_custom_nodes.error` +- `comfy.desktop.snapshot.restore_pip_packages.error` + +## Open-ended bridge caveat + +The hosted ComfyUI bridge accepts an arbitrary `event: string` through +`src/types/comfyDesktopBridge.ts`. Code loaded remotely inside ComfyUI can +therefore submit additional names that are not present in this repository. +This inventory is exhaustive for event names defined by this repository, but +cannot enumerate events originating from external hosted code. diff --git a/locales/en.json b/locales/en.json index feda5029d..c8d6bbb0b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1440,6 +1440,8 @@ "addExistingInstall": "Add Existing Instance", "loadSnapshot": "Load Snapshot", "signIn": "Log in", + "performanceTest": "Performance Tests", + "benchmarks": "Benchmarks", "globalSettings": "Desktop Settings", "sendFeedback": "Send Feedback", "returnToDashboard": "Return to Dashboard", @@ -1448,6 +1450,120 @@ "exitAllWindows": "Quit Desktop", "skipOnboarding": "Skip Onboarding" }, + "performanceTest": { + "title": "Performance Tests", + "description": "Run performance tests against your ComfyUI instances.", + "selectInstance": "1. Select an instance", + "workspaceLabel": "Workspace", + "instanceLabel": "Instance", + "selectInstancePlaceholder": "Select an instance", + "dropWorkflow": "2. Drop a workflow in API format", + "dropWorkflowHint": "Drop a workflow .json file here, or click to browse", + "importingWorkflow": "Importing workflow...", + "importFailed": "Could not import the workflow.", + "deleteWorkflow": "Delete workflow", + "deleteFailed": "Could not delete the workflow.", + "run": "Run", + "stop": "Stop", + "stopping": "Stopping...", + "stopFailed": "Could not stop the instance.", + "measurementSettings": "3. Set measurements settings", + "warmupRuns": "Warm-up runs", + "measuredRuns": "Measured runs", + "logsPlaceholder": "Instance logs will appear here.", + "results": "Results", + "resultsPlaceholder": "Performance test results will appear here.", + "workflowFileName": "Workflow file", + "fastestRun": "Fastest run", + "slowestRun": "Slowest run", + "averageRunDuration": "Average run", + "medianRunDuration": "Median run", + "measuredRunCount": "Measured runs", + "failedRunCount": "Failed runs", + "runProgress": "Progress", + "runProgressCount": "{completed} of {total} runs completed", + "runDurationChart": "Run duration aggregates", + "device": "Compute device", + "vram": "VRAM", + "ram": "RAM", + "pytorchVersion": "PyTorch version", + "xformersVersion": "xFormers version", + "systemInformation": "System information", + "cpu": "CPU", + "cpuCores": "CPU cores", + "operatingSystem": "Operating system", + "architecture": "Architecture", + "openResultsFolder": "Open folder", + "imageTitle": "Performance Test: {workflowName}", + "exportResultsImage": "Export results", + "exportingImage": "Exporting image...", + "exportImageFailed": "Could not export the results image.", + "running": "Running...", + "launchFailed": "Failed to start the instance.", + "submittingRuns": "Submitting {warmupCount} warm-up runs and {count} measured runs...", + "completedRuns": "Finished {count} measured runs ({failed} failed). Final response saved to {path}", + "submitFailed": "Failed to submit the performance test workflow." + }, + "benchmarks": { + "title": "Benchmarks", + "description": "Compare results from your performance tests.", + "runsLibrary": "Runs library", + "openFolder": "Select folder", + "refresh": "Refresh sessions", + "searchPlaceholder": "Search runs...", + "allWorkspaces": "All workspaces", + "allInstances": "All instances", + "allHardware": "All hardware", + "allWorkflows": "All workflows", + "columns": "Properties", + "columnsToDisplay": "Columns to display", + "unmanagedWorkspace": "Unmanaged", + "unknownHardware": "Unknown hardware", + "session": "Session", + "dateTime": "Date / Time", + "workflow": "Workflow", + "instance": "Instance", + "workspace": "Workspace", + "runs": "Runs", + "hardware": "GPU / Hardware", + "fastest": "Fastest", + "average": "Average", + "median": "Median", + "slowest": "Slowest", + "measuredRuns": "Measured runs", + "selectVisible": "Select all visible runs", + "selectRun": "Select {workflow}", + "actions": "Actions", + "deleteRecord": "Delete {workflow} benchmark", + "deleteConfirmTitle": "Delete {workflow}?", + "deleteConfirmMessage": "This will permanently delete all files for session {session}. This cannot be undone.", + "deleteFiles": "Delete files", + "deleteErrorTitle": "Could not delete benchmark", + "deleteErrorMessage": "The benchmark files could not be deleted.", + "sessionName": "Session name", + "editSessionName": "Edit session {session}", + "renameErrorTitle": "Could not rename session", + "renameErrorMessage": "The session folder could not be renamed.", + "sessionNameRequired": "Enter a session name.", + "comparison": "Comparison", + "sortComparison": "Sort comparison", + "manualSort": "Manual sort", + "switchSortAscending": "Switch to ascending sort", + "switchSortDescending": "Switch to descending sort", + "reorderComparisonColumn": "Reorder {workflow} comparison column", + "reorderComparisonHint": "Drag to reorder. You can also press Alt+Left or Alt+Right.", + "comparisonImageTitle": "Benchmark Comparison", + "exportResultsImage": "Export results", + "exportingImage": "Exporting image...", + "exportImageFailed": "Could not export the comparison image.", + "metric": "Metric", + "durationRange": "Duration range (min → max)", + "durationRangeHint": "Each line shows the fastest and slowest run, with the average marked.", + "selectPrompt": "Select runs from the library to compare them.", + "empty": "No performance test results yet. Complete a Performance Test to add a run.", + "noMatches": "No runs match these filters.", + "loadError": "Could not load performance test results." + }, "downloadsPopup": { "title": "Downloads", "empty": "No downloads yet", diff --git a/locales/zh.json b/locales/zh.json index 14c114ca7..3b1e032d4 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -1440,6 +1440,8 @@ "addExistingInstall": "添加现有实例", "loadSnapshot": "加载快照", "signIn": "登录", + "performanceTest": "性能测试", + "benchmarks": "基准比较", "globalSettings": "桌面端设置", "sendFeedback": "发送反馈", "returnToDashboard": "返回仪表板", @@ -1448,6 +1450,120 @@ "exitAllWindows": "退出桌面端", "skipOnboarding": "跳过引导" }, + "performanceTest": { + "title": "性能测试", + "description": "针对您的 ComfyUI 实例运行性能测量。", + "selectInstance": "1. 选择实例", + "workspaceLabel": "工作区", + "instanceLabel": "实例", + "selectInstancePlaceholder": "选择实例", + "dropWorkflow": "2. 拖放 API 格式的工作流", + "dropWorkflowHint": "将工作流 .json 文件拖放到此处,或点击浏览", + "importingWorkflow": "正在导入工作流...", + "importFailed": "无法导入工作流。", + "deleteWorkflow": "删除工作流", + "deleteFailed": "无法删除工作流。", + "run": "运行", + "stop": "停止", + "stopping": "正在停止...", + "stopFailed": "无法停止实例。", + "measurementSettings": "3. 设置测量参数", + "warmupRuns": "预热运行次数", + "measuredRuns": "测量运行次数", + "logsPlaceholder": "实例日志将显示在此处。", + "results": "结果", + "resultsPlaceholder": "性能测试结果将显示在此处。", + "workflowFileName": "工作流文件", + "fastestRun": "最快运行", + "slowestRun": "最慢运行", + "averageRunDuration": "平均运行时长", + "medianRunDuration": "运行时长中位数", + "measuredRunCount": "测量运行数", + "failedRunCount": "失败运行数", + "runProgress": "进度", + "runProgressCount": "已完成 {completed}/{total} 次运行", + "runDurationChart": "运行时长汇总", + "device": "计算设备", + "vram": "显存", + "ram": "内存", + "pytorchVersion": "PyTorch 版本", + "xformersVersion": "xFormers 版本", + "systemInformation": "系统信息", + "cpu": "CPU", + "cpuCores": "CPU 核心", + "operatingSystem": "操作系统", + "architecture": "架构", + "openResultsFolder": "打开文件夹", + "imageTitle": "性能测试:{workflowName}", + "exportResultsImage": "导出结果", + "exportingImage": "正在导出图像...", + "exportImageFailed": "无法导出结果图像。", + "running": "正在运行...", + "launchFailed": "无法启动实例。", + "submittingRuns": "正在提交 {warmupCount} 次预热运行和 {count} 次测量运行...", + "completedRuns": "已完成 {count} 次测量运行({failed} 次失败)。最终响应已保存到 {path}", + "submitFailed": "无法提交基准工作流。" + }, + "benchmarks": { + "title": "性能比较", + "description": "比较性能测试结果。", + "runsLibrary": "运行记录", + "openFolder": "选择文件夹", + "refresh": "刷新会话", + "searchPlaceholder": "搜索工作流、实例或硬件...", + "allWorkspaces": "所有工作区", + "allInstances": "所有实例", + "allHardware": "所有硬件", + "allWorkflows": "所有工作流", + "columns": "属性", + "columnsToDisplay": "要显示的列", + "unmanagedWorkspace": "未管理", + "unknownHardware": "未知硬件", + "session": "会话", + "dateTime": "日期 / 时间", + "workflow": "工作流", + "instance": "实例", + "workspace": "工作区", + "runs": "运行次数", + "hardware": "GPU / 硬件", + "fastest": "最快", + "average": "平均", + "median": "中位数", + "slowest": "最慢", + "measuredRuns": "测量运行", + "selectVisible": "选择所有可见运行", + "selectRun": "选择 {workflow}", + "actions": "操作", + "deleteRecord": "删除 {workflow} 基准测试", + "deleteConfirmTitle": "删除 {workflow}?", + "deleteConfirmMessage": "这将永久删除会话 {session} 的所有文件。此操作无法撤销。", + "deleteFiles": "删除文件", + "deleteErrorTitle": "无法删除基准测试", + "deleteErrorMessage": "无法删除基准测试文件。", + "sessionName": "会话名称", + "editSessionName": "编辑会话 {session}", + "renameErrorTitle": "无法重命名会话", + "renameErrorMessage": "无法重命名会话文件夹。", + "sessionNameRequired": "请输入会话名称。", + "comparison": "比较", + "sortComparison": "排序比较", + "manualSort": "手动排序", + "switchSortAscending": "切换为升序", + "switchSortDescending": "切换为降序", + "reorderComparisonColumn": "重新排列 {workflow} 比较列", + "reorderComparisonHint": "拖动以重新排序。也可以按 Alt+左箭头或 Alt+右箭头。", + "comparisonImageTitle": "基准比较", + "exportResultsImage": "导出结果", + "exportingImage": "正在导出图像...", + "exportImageFailed": "无法导出比较图像。", + "metric": "指标", + "durationRange": "耗时范围(最短 → 最长)", + "durationRangeHint": "每条线显示最快和最慢运行,并标记平均值。", + "selectPrompt": "从运行记录中选择要比较的运行。", + "empty": "暂无性能测试结果。请完成一次性能测试以添加运行记录。", + "noMatches": "没有符合筛选条件的运行。", + "loadError": "无法加载性能测试结果。" + }, "downloadsPopup": { "title": "下载", "empty": "还没有下载", diff --git a/src/main/cloud/tokenStore.test.ts b/src/main/cloud/tokenStore.test.ts index fb641c5ce..63c6d2a0f 100644 --- a/src/main/cloud/tokenStore.test.ts +++ b/src/main/cloud/tokenStore.test.ts @@ -20,6 +20,7 @@ import { activateWorkspace, clearTokens, getAuthStatus, + getCachedWorkspaceName, loadTokens, loadWorkspaceTokens, replaceWorkspaceTokens, @@ -106,6 +107,7 @@ describe('workspace token vault', () => { workspaceId: 'w1', workspaceName: 'Workspace One' }) + expect(getCachedWorkspaceName('w2')).toBe('Workspace Two') }) it('rejects workspace names from a request belonging to another account', () => { diff --git a/src/main/cloud/tokenStore.ts b/src/main/cloud/tokenStore.ts index 53b46afac..94218a34a 100644 --- a/src/main/cloud/tokenStore.ts +++ b/src/main/cloud/tokenStore.ts @@ -177,6 +177,11 @@ export function saveWorkspaceNames(accessToken: string, workspaces: Workspace[]) persistVault(vault) } +/** Return a cached workspace name by durable id without changing the active workspace. */ +export function getCachedWorkspaceName(workspaceId: string): string | null { + return loadVault()?.workspaceNames?.[workspaceId] ?? null +} + /** Activate a cached workspace. Returns null when it has never been authorized. */ export function activateWorkspace(workspaceId: string): AuthTokens | null { const vault = loadVault() diff --git a/src/main/host/createHostWindow.ts b/src/main/host/createHostWindow.ts index 5b3f64b18..66b20e3b8 100644 --- a/src/main/host/createHostWindow.ts +++ b/src/main/host/createHostWindow.ts @@ -563,7 +563,7 @@ export function createHostWindow(opts: CreateHostWindowOpts): CreateHostWindowRe const comfyWindow = new BrowserWindow({ ...windowOptions, show: !opts.initiallyHidden, - minWidth: 800, + minWidth: 1200, minHeight: 600, icon: APP_ICON, title: opts.windowTitle, diff --git a/src/main/host/panelView.test.ts b/src/main/host/panelView.test.ts index 95c90557d..19c610181 100644 --- a/src/main/host/panelView.test.ts +++ b/src/main/host/panelView.test.ts @@ -129,6 +129,30 @@ describe('setActivePanel', () => { expect(fixture.titleBarWc.sent).toHaveLength(0) }) + it.each(['performance-test', 'benchmarks'] as const)( + 'reasserts an already-active %s page so a stale dashboard renderer can recover', + (panel) => { + const fixture = makeEntry({ activePanel: panel }) + const panelWc = makeWc() + fixture.entry.panelView = { + webContents: panelWc + } as unknown as ComfyWindowEntry['panelView'] + comfyWindows.set(fixture.entry.windowKey, fixture.entry) + + setActivePanel(fixture.entry.windowKey, panel) + + expect(fixture.layoutCalls).toBe(1) + expect(panelWc.sent).toContainEqual({ + channel: 'panel-switch', + args: [{ panel, installationId: '' }] + }) + expect(fixture.titleBarWc.sent).toContainEqual({ + channel: 'comfy-titlebar:panel-changed', + args: [panel] + }) + } + ) + it('no-ops when the windowKey does not resolve to an entry', () => { expect(() => setActivePanel(999_999, 'feedback')).not.toThrow() }) diff --git a/src/main/host/panelView.ts b/src/main/host/panelView.ts index e8f016e01..00438f622 100644 --- a/src/main/host/panelView.ts +++ b/src/main/host/panelView.ts @@ -33,7 +33,13 @@ function opaquePanelBg(): string { * an opaque surface instead of black. Overlay modes (downloads / feedback) * deliberately stay transparent. */ function isOpaqueBodyMode(mode: BodyMode): boolean { - return mode === 'chooser' || mode === 'new-install' || mode === 'comfy-lifecycle' + return ( + mode === 'chooser' || + mode === 'performance-test' || + mode === 'benchmarks' || + mode === 'new-install' || + mode === 'comfy-lifecycle' + ) } /** @@ -162,7 +168,12 @@ export function focusActiveBody(entry: ComfyWindowEntry): void { export function setActivePanel(windowKey: number, panel: ComfyPanelKey): void { const entry = comfyWindows.get(windowKey) if (!entry || entry.window.isDestroyed()) return - if (entry.activePanel === panel) return + // Full-page tool selections must reassert their renderer state. A dashboard + // renderer can still be showing after main has recorded one of these keys; + // treating the menu click as a no-op then strands the user on the dashboard + // until they select a different page first. + const reassertFullPageTool = panel === 'performance-test' || panel === 'benchmarks' + if (entry.activePanel === panel && !reassertFullPageTool) return entry.activePanel = panel const mode = computeBodyMode(entry) diff --git a/src/main/host/registry.ts b/src/main/host/registry.ts index 5e2bea47c..f27b80816 100644 --- a/src/main/host/registry.ts +++ b/src/main/host/registry.ts @@ -19,6 +19,8 @@ export const hostInstallEvents = new EventEmitter() */ export type ComfyPanelKey = | 'comfy' + | 'performance-test' + | 'benchmarks' | 'feedback' | 'new-install' | 'track' @@ -37,6 +39,8 @@ export type ComfyPanelKey = export const VALID_PANELS: ReadonlySet = new Set([ 'comfy', + 'performance-test', + 'benchmarks', 'feedback', 'new-install', 'track', @@ -57,6 +61,8 @@ export type BodyMode = | 'comfy-lifecycle' | 'feedback' | 'chooser' + | 'performance-test' + | 'benchmarks' /** Mirror of the `'progress'` ComfyPanelKey; forces the panel to fully cover * the canvas while a picker-driven ProgressModal is mounted. */ | 'progress' diff --git a/src/main/lib/hardwareTap.test.ts b/src/main/lib/hardwareTap.test.ts index 9e7566174..7e2949d6f 100644 --- a/src/main/lib/hardwareTap.test.ts +++ b/src/main/lib/hardwareTap.test.ts @@ -383,6 +383,33 @@ describe('createHardwareTap', () => { }) }) + it('exposes the same accelerator details parsed for telemetry', () => { + const tap = createHardwareTap({ installationId: 'inst-1' }) + tap.ingest('Total VRAM 24576 MB, total RAM 65461 MB\n', 'stdout') + tap.ingest('pytorch version: 2.10.0+cu130\n', 'stdout') + tap.ingest('Device: cuda:0 NVIDIA GeForce RTX 4090 : native\n', 'stdout') + + expect(tap.getAcceleratorInfo()).toEqual({ + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native', + devices: [ + { + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native' + } + ], + vramMb: 24576, + ramMb: 65461, + pytorchVersion: '2.10.0+cu130', + xformersVersion: null, + cudaDeviceSet: null + }) + }) + it('does not promote a cpu device to gpu person properties', () => { const tap = createHardwareTap({ installationId: 'inst-1' }) tap.ingest('Device: cpu\n', 'stdout') diff --git a/src/main/lib/hardwareTap.ts b/src/main/lib/hardwareTap.ts index b1fb346f1..81a2f463f 100644 --- a/src/main/lib/hardwareTap.ts +++ b/src/main/lib/hardwareTap.ts @@ -46,6 +46,15 @@ export interface AcceleratorInfo { backend: string | null } +export interface AcceleratorSnapshot extends AcceleratorInfo { + devices: AcceleratorInfo[] + vramMb: number | null + ramMb: number | null + pytorchVersion: string | null + xformersVersion: string | null + cudaDeviceSet: number | null +} + const DEVICE_LINE = /^Device:\s*(.+)$/ const VRAM_LINE = /^Total VRAM\s+(\d+)\s*MB,\s*total RAM\s+(\d+)\s*MB/i const PYTORCH_LINE = /^pytorch version:\s*(.+)$/i @@ -118,6 +127,7 @@ export function createHardwareTap(opts: { }): { ingest: (chunk: string, source: 'stdout' | 'stderr') => void beginBoot: () => void + getAcceleratorInfo: () => AcceleratorSnapshot | null flushSummary: () => void } { const baseContext = { @@ -214,6 +224,27 @@ export function createHardwareTap(opts: { } } + function getAcceleratorInfo(): AcceleratorSnapshot | null { + if (devices.length === 0) return null + const primary = devices[0]! + const primaryName = + primary.deviceName ?? + (primary.deviceType !== 'cpu' && primary.deviceType !== 'mps' ? directmlDeviceName : null) + return { + ...primary, + deviceName: primaryName, + devices: devices.map((device, index) => ({ + ...device, + deviceName: index === 0 ? primaryName : device.deviceName + })), + vramMb, + ramMb, + pytorchVersion, + xformersVersion, + cudaDeviceSet + } + } + function handleLine(line: string): void { // Strip a leading `[LEVEL] ` tag (ComfyUI Desktop's bundled build) so the // anchored parsers below match both the prefixed and bare log formats. @@ -312,6 +343,7 @@ export function createHardwareTap(opts: { // Drop any incomplete lines from the previous (now-dead) process streams. lineBuffer.reset() }, + getAcceleratorInfo, flushSummary(): void { // Process complete-but-unterminated final lines independently so a bad // stdout tail cannot suppress a valid stderr tail (or vice versa). diff --git a/src/main/lib/ipc/registerAppHandlers.ts b/src/main/lib/ipc/registerAppHandlers.ts index 0ee4231bd..ce112913e 100644 --- a/src/main/lib/ipc/registerAppHandlers.ts +++ b/src/main/lib/ipc/registerAppHandlers.ts @@ -3,6 +3,7 @@ import { dialog, shell, BrowserWindow, + app, fs, path, os, @@ -25,16 +26,35 @@ import { openPath, listSnapshots, diffSnapshots, - buildInstallationDdContext + buildInstallationDdContext, + _runningSessions } from './shared' import si from 'systeminformation' +import type { SystemInfo } from '../../../types/ipc' import type { FieldOption } from './shared' import * as mainTelemetry from '../telemetry' import { getDeviceId } from '../deviceId' +import { getCachedWorkspaceName } from '../../cloud/tokenStore' +import { getCloudSession } from '../../devplatform/session' import { getCloudFreeRunsEnabledAsync } from '../cloudFreeRuns' import { getUserTierAsync } from '../userTier' import { getStableTags } from '../comfyui-releases' import { deriveGpuTier } from '../../../shared/gpuTier' +import { PERSONAL_WORKSPACE_ID } from '../../../shared/workspaces' +import { + calculatePerformanceTestStatistics, + deletePerformanceTestBenchmark, + deletePerformanceTestWorkflow, + listPerformanceTestBenchmarks, + renamePerformanceTestBenchmark, + readPerformanceTestResultsSummary, + savePerformanceTestJobsResponse, + savePerformanceTestLogs, + savePerformanceTestResultsSummary, + storePerformanceTestWorkflow, + submitPerformanceTestWorkflow, + waitForPerformanceTestJobs +} from '../performanceTestWorkflows' export function registerAppHandlers(): void { // App version @@ -137,6 +157,259 @@ export function registerAppHandlers(): void { return filePaths[0] }) + ipcMain.handle('import-performance-test-workflow', async (_event, droppedFilePath?: string) => { + let sourcePath = typeof droppedFilePath === 'string' ? droppedFilePath : '' + if (!sourcePath) { + const win = BrowserWindow.fromWebContents(_event.sender) + if (!win) return { ok: false, message: 'No window.' } + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + filters: [{ name: 'JSON', extensions: ['json'] }], + properties: ['openFile'] + }) + if (canceled || filePaths.length === 0) return { ok: false, canceled: true } + sourcePath = filePaths[0]! + } + try { + const filePath = await storePerformanceTestWorkflow(sourcePath, app.getPath('userData')) + return { ok: true, filePath } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + }) + + ipcMain.handle('delete-performance-test-workflow', async (_event, filePath: string) => { + try { + await deletePerformanceTestWorkflow(filePath, app.getPath('userData')) + return { ok: true } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + }) + + ipcMain.handle('save-performance-test-logs', async (_event, filePath: string, logs: string) => { + try { + if (typeof logs !== 'string') throw new Error('Invalid performance test logs.') + const logsPath = await savePerformanceTestLogs(logs, filePath, app.getPath('userData')) + return { ok: true, logsPath } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + }) + + ipcMain.handle( + 'list-performance-test-benchmarks', + async (_event, selectedFolderPath?: string) => { + const folderPath = + selectedFolderPath || path.join(app.getPath('userData'), 'performance-tests') + return { + folderPath, + benchmarks: await listPerformanceTestBenchmarks(folderPath) + } + } + ) + + ipcMain.handle( + 'delete-performance-test-benchmark', + async (_event, folderPath: string, sessionId: string) => { + try { + if (typeof folderPath !== 'string' || !path.isAbsolute(folderPath)) { + throw new Error('Invalid performance test folder.') + } + await deletePerformanceTestBenchmark(folderPath, sessionId) + return { ok: true } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + } + ) + + ipcMain.handle( + 'rename-performance-test-benchmark', + async (_event, folderPath: string, sessionId: string, newSessionId: string) => { + try { + if (typeof folderPath !== 'string' || !path.isAbsolute(folderPath)) { + throw new Error('Invalid performance test folder.') + } + if (typeof sessionId !== 'string' || typeof newSessionId !== 'string') { + throw new Error('Invalid performance test benchmark ID.') + } + await renamePerformanceTestBenchmark(folderPath, sessionId, newSessionId) + return { ok: true, sessionId: newSessionId } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + } + ) + + ipcMain.handle('read-performance-test-results-summary', (_event, filePath: string) => + readPerformanceTestResultsSummary(filePath, app.getPath('userData')) + ) + + ipcMain.handle( + 'export-results-image', + async ( + _event, + png: ArrayBuffer, + imageType: 'performance-test' | 'benchmark-comparison', + defaultPath?: string + ) => { + const contents = png instanceof ArrayBuffer ? Buffer.from(png) : Buffer.alloc(0) + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + if ( + contents.length === 0 || + contents.length > 50_000_000 || + !contents.subarray(0, pngSignature.length).equals(pngSignature) + ) { + return { ok: false, message: 'Invalid results image.' } + } + const exportConfig = + imageType === 'benchmark-comparison' + ? { title: 'Export benchmark comparison', prefix: 'benchmark-comparison' } + : imageType === 'performance-test' + ? { title: 'Export performance test results', prefix: 'performance-test-results' } + : null + if (!exportConfig) return { ok: false, message: 'Invalid results image type.' } + const win = BrowserWindow.fromWebContents(_event.sender) + if (!win) return { ok: false, message: 'No window.' } + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: exportConfig.title, + buttonLabel: 'Export here', + defaultPath, + properties: ['openDirectory', 'createDirectory'] + }) + if (canceled || filePaths.length === 0) return { ok: false, canceled: true } + + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23) + const filePath = path.join(filePaths[0]!, `${exportConfig.prefix}-${timestamp}.png`) + await fs.promises.writeFile(filePath, contents) + return { ok: true, filePath } + } catch (error) { + return { ok: false, message: (error as Error)?.message || String(error) } + } + } + ) + + ipcMain.handle( + 'run-performance-test-workflow', + async ( + _event, + sessionId: string, + filePath: string, + measuredRuns: number, + warmupRuns: number + ) => { + try { + if (typeof sessionId !== 'string' || !sessionId.startsWith('performance-test:')) { + throw new Error('Invalid performance test session.') + } + const session = _runningSessions.get(sessionId) + if (!session) throw new Error('The performance test instance is not running.') + const sourceInstallationId = + session.sourceInstallationId ?? sessionId.slice('performance-test:'.length) + const sourceInstallation = await installations.get(sourceInstallationId) + const workspaceId = sourceInstallation?.workspaceId ?? null + let workspaceName = + workspaceId === PERSONAL_WORKSPACE_ID + ? 'Personal' + : workspaceId + ? getCachedWorkspaceName(workspaceId) + : null + if (workspaceId) { + try { + const owningWorkspace = (await getCloudSession().listWorkspaces()).find( + (workspace) => workspace.id === workspaceId + ) + workspaceName = owningWorkspace?.name ?? workspaceName + } catch { + // The id-keyed cache remains accurate when workspace refresh is unavailable. + } + } + const workspace = { id: workspaceId, name: workspaceName } + const sessionUrl = session.url || `http://127.0.0.1:${session.port}` + const promptIds = await submitPerformanceTestWorkflow( + filePath, + app.getPath('userData'), + sessionUrl, + measuredRuns, + warmupRuns + ) + const preparationRuns = warmupRuns + const measuredPromptIds = promptIds.slice(warmupRuns) + const jobsResponse = await waitForPerformanceTestJobs( + sessionUrl, + promptIds, + fetch, + undefined, + (completedRuns, totalRuns) => { + if (!_event.sender.isDestroyed()) { + _event.sender.send('performance-test-progress', { + sessionId, + completedRuns, + totalRuns + }) + } + } + ) + const submittedPromptIds = new Set(measuredPromptIds) + const successfulRuns = jobsResponse.jobs.filter( + (job) => submittedPromptIds.has(job.id) && job.status === 'completed' + ).length + const failedRuns = jobsResponse.jobs.filter( + (job) => submittedPromptIds.has(job.id) && job.status !== 'completed' + ).length + const statistics = calculatePerformanceTestStatistics(jobsResponse, measuredPromptIds) + const resultPath = await savePerformanceTestJobsResponse( + jobsResponse, + filePath, + app.getPath('userData') + ) + const hardware = session.getAcceleratorInfo?.() ?? null + const systemInfo = await getSystemInfo() + const resultsSummaryPath = await savePerformanceTestResultsSummary( + statistics, + { + id: sourceInstallationId, + name: session.installationName + }, + workspace, + hardware, + systemInfo, + filePath, + app.getPath('userData'), + successfulRuns, + failedRuns + ) + const resultsSummary = await readPerformanceTestResultsSummary( + resultsSummaryPath, + app.getPath('userData') + ) + return { + ok: true, + submitted: measuredRuns, + preparationRuns, + totalSubmitted: measuredRuns + preparationRuns, + promptIds, + resultPath, + resultsSummaryPath, + statistics, + hardware, + systemInfo, + resultsSummary, + failedRuns + } + } catch (error) { + return { + ok: false, + submitted: 0, + preparationRuns: 0, + totalSubmitted: 0, + message: (error as Error)?.message || String(error) + } + } + } + ) + ipcMain.handle('open-path', (_event, targetPath: string) => { if (typeof targetPath !== 'string' || !targetPath) return '' if (/^https?:\/\//i.test(targetPath)) return shell.openExternal(targetPath) @@ -196,7 +469,7 @@ export function registerAppHandlers(): void { return hardwareProbe } - ipcMain.handle('get-system-info', async () => { + async function getSystemInfo(): Promise { const hardware = await probeHardwareCached() const cpus = os.cpus() const allInstalls = await installations.list() @@ -224,8 +497,10 @@ export function registerAppHandlers(): void { update_channel: (inst.updateChannel as string) || 'stable', status: (inst.status as string) || 'ready' })) - } - }) + } as SystemInfo + } + + ipcMain.handle('get-system-info', getSystemInfo) async function probeHardware(): Promise> { const gpu = await detectGPUCached() diff --git a/src/main/lib/ipc/registerSessionHandlers.ts b/src/main/lib/ipc/registerSessionHandlers.ts index a5703ff74..e87421021 100644 --- a/src/main/lib/ipc/registerSessionHandlers.ts +++ b/src/main/lib/ipc/registerSessionHandlers.ts @@ -8,10 +8,10 @@ import { REQUIRES_STOPPED, _onStop, _operationAborts, - _runningSessions, _getPublicSessions, _getLaunchingInstances, _getStoppingInstallationIds, + hasRunningSessionForInstallation, stopRunning } from './shared' import { dispatchSessionAction, _getActiveOperations } from './sessionActions' @@ -72,7 +72,7 @@ export function registerSessionHandlers(): void { const maybeInst = await installations.get(installationId) if (!maybeInst) return { ok: false, message: 'Installation not found.' } const inst = maybeInst - if (REQUIRES_STOPPED.has(actionId) && _runningSessions.has(installationId)) { + if (REQUIRES_STOPPED.has(actionId) && hasRunningSessionForInstallation(installationId)) { return { ok: false, message: i18n.t('errors.stopRequired'), running: true } } if (REQUIRES_STOPPED.has(actionId) && _operationAborts.has(installationId)) { @@ -84,7 +84,15 @@ export function registerSessionHandlers(): void { return { ok: false, message: i18n.t('errors.operationInProgress', { operation }) } } - return dispatchSessionAction({ event: _event, installationId, inst, actionData }, actionId) + const requestedSessionId = actionData?.sessionIdOverride + const sessionId = + actionId === 'launch' && requestedSessionId === `performance-test:${installationId}` + ? requestedSessionId + : undefined + return dispatchSessionAction( + { event: _event, installationId, sessionId, inst, actionData }, + actionId + ) } ) } diff --git a/src/main/lib/ipc/sessionActions/launch.test.ts b/src/main/lib/ipc/sessionActions/launch.test.ts index a1b20285c..c8a6b6e7c 100644 --- a/src/main/lib/ipc/sessionActions/launch.test.ts +++ b/src/main/lib/ipc/sessionActions/launch.test.ts @@ -138,7 +138,9 @@ import { isCrashedExit, onProcessTerminated, writeLog, - _cleanupFailedLaunchSetup + _cleanupFailedLaunchSetup, + _resolveLaunchMode, + _resolvePortConflictPolicy } from './launch' import * as assetsTapModule from '../../assetsTap' import type { ActionContext } from './types' @@ -155,6 +157,7 @@ import { _markLaunching, _operationAborts, _pendingPorts, + _runningSessions, _reservePort } from '../shared' import type { ChildProcess, InstallationRecord } from '../shared' @@ -196,6 +199,58 @@ describe('desktopFeatureFlags', () => { }) }) +describe('_resolveLaunchMode', () => { + it('allows a launch-scoped console override without changing the installation', () => { + const installation = { ...installOf('standalone'), launchMode: 'window' } + + expect(_resolveLaunchMode(installation, { launchModeOverride: 'console' })).toBe('console') + expect(installation.launchMode).toBe('window') + }) + + it('uses the persisted mode for unsupported overrides', () => { + const installation = { ...installOf('standalone'), launchMode: 'window' } + + expect(_resolveLaunchMode(installation, { launchModeOverride: 'external' })).toBe('window') + }) +}) + +describe('_resolvePortConflictPolicy', () => { + it('allows a launch-scoped automatic port without changing the installation', () => { + const installation = { + ...installOf('standalone'), + launchArgs: '--enable-manager --port 8188', + portConflict: 'prompt' + } + + expect( + _resolvePortConflictPolicy( + installation, + { portConflict: 'prompt' }, + { + autoPortOnConflict: true + } + ) + ).toEqual({ mode: 'auto', portIsExplicit: false }) + expect(installation).toMatchObject({ + launchArgs: '--enable-manager --port 8188', + portConflict: 'prompt' + }) + }) + + it('preserves the configured policy and explicit port for normal launches', () => { + const installation = { + ...installOf('standalone'), + launchArgs: '--port=8188', + portConflict: 'prompt' + } + + expect(_resolvePortConflictPolicy(installation, { portConflict: 'auto' })).toEqual({ + mode: 'prompt', + portIsExplicit: true + }) + }) +}) + describe('isCrashedExit', () => { it('treats a clean exit (code 0, no signal) as not crashed', () => { expect(isCrashedExit(0, null)).toBe(false) @@ -334,6 +389,27 @@ describe('handleLaunch model-download startup await (#1322)', () => { modelStartup.impl = null }) + it('allows an isolated performance test session while the installation is already running', async () => { + const installationId = 'running-install' + const sessionId = `performance-test:${installationId}` + _runningSessions.set(installationId, { + proc: null, + port: 8188, + mode: 'window', + installationName: 'Running Install', + startedAt: Date.now() + }) + + try { + const result = await handleLaunch({ ...ctxFor(installationId), sessionId }) + expect(result.message).toMatch(/unknownSource|unrecognized source/) + expect(result.message).not.toMatch(/alreadyRunning/i) + } finally { + _runningSessions.delete(installationId) + _operationAborts.delete(sessionId) + } + }) + it('never blocks the launch while incomplete files are visible under final model names', async () => { modelStartup.impl = async () => ({ safe: false, diff --git a/src/main/lib/ipc/sessionActions/launch.ts b/src/main/lib/ipc/sessionActions/launch.ts index 164dda849..85171a06d 100644 --- a/src/main/lib/ipc/sessionActions/launch.ts +++ b/src/main/lib/ipc/sessionActions/launch.ts @@ -465,6 +465,32 @@ export function writeLog(stream: WriteStream, text: string): void { if (!stream.writableEnded) stream.write(stripAnsi(text)) } +export function _resolveLaunchMode( + inst: InstallationRecord, + actionData?: Record +): string { + if (actionData?.launchModeOverride === 'console') return 'console' + return (inst.launchMode as string | undefined) || 'window' +} + +export function _resolvePortConflictPolicy( + inst: InstallationRecord, + defaults: Record, + actionData?: Record +): { mode: string; portIsExplicit: boolean } { + const autoPortOnConflict = actionData?.autoPortOnConflict === true + return { + mode: autoPortOnConflict + ? 'auto' + : (inst.portConflict as string | undefined) || + (defaults.portConflict as string | undefined) || + 'auto', + portIsExplicit: + actionData?.portOverride != null || + (!autoPortOnConflict && /(?:^|\s)--port(?:\s|=|$)/.test(String(inst.launchArgs ?? ''))) + } +} + /** Builds the assets tap, substituting an inert one if construction throws. * Deliberately stricter than the neighbouring `createHardwareTap`, whose * construction failures propagate and abort the launch: this tap is pure @@ -543,7 +569,8 @@ export function _cleanupFailedLaunchSetup( export async function handleLaunch(ctx: ActionContext): Promise { const { installationId } = ctx - if (_runningSessions.has(installationId)) { + const sessionId = ctx.sessionId ?? installationId + if (_runningSessions.has(sessionId)) { return { ok: false, message: i18n.t('errors.alreadyRunning') } } // No `_hasActiveLaunch` here: this guard, `_beginLaunch`, and runLaunch's @@ -551,28 +578,28 @@ export async function handleLaunch(ctx: ActionContext): Promise { // launch can never slip between them. Checking it would instead reject a // legitimate restart relaunch during the post-registration window (session // up, handler still draining post-launch work like the template gate). - if (_operationAborts.has(installationId)) { + if (_operationAborts.has(sessionId)) { return { ok: false, message: 'Another operation is already running for this installation.' } } // Track the launch for its ENTIRE handler lifetime so `cancelLaunching` can // abort it at any point - including the pre-spawn prep that runs before the // launching marker exists. The finally is the single teardown-complete // signal: `cancelLaunching` awaits it before letting a restart relaunch. - const launch = _beginLaunch(installationId) + const launch = _beginLaunch(sessionId) try { return await runLaunch(ctx, launch.abort) } finally { - if (_operationAborts.get(installationId) === launch.abort) - _operationAborts.delete(installationId) - _endLaunch(installationId, launch) + if (_operationAborts.get(sessionId) === launch.abort) _operationAborts.delete(sessionId) + _endLaunch(sessionId, launch) } } async function runLaunch( - { event, installationId, inst: instArg, actionData }: ActionContext, + { event, installationId, sessionId: runtimeSessionId, inst: instArg, actionData }: ActionContext, abort: AbortController ): Promise { let inst = instArg + const sessionId = runtimeSessionId ?? installationId // Synthetic repair steps that ran during launch prep, prepended to the launch // progress in display order (e.g. a source rollback, then a PyTorch restore). const preLaunchPhases: PreLaunchPhase[] = [] @@ -599,9 +626,9 @@ async function runLaunch( let coreBetaReported = false // Claim the operation slot for the whole launch, prep included, so no other // operation can start against this install while the launch is preparing. - _operationAborts.set(installationId, abort) + _operationAborts.set(sessionId, abort) // Drop retained crash detail so the lifecycle view doesn't resurface it. - clearCrash(installationId) + clearCrash(sessionId) // The startup model-download pass (migrate legacy final-path partials, // hydrate staged `.part` downloads) runs to completion before ComfyUI can // scan the model dirs, so a truncated file cannot masquerade as a loadable @@ -654,7 +681,7 @@ async function runLaunch( } const sender = event.sender - const sendProgress = makeSendProgress(sender, installationId) + const sendProgress = makeSendProgress(sender, sessionId) // A build install whose background model staging never finished (crash, // abort, staging failure, or a record written before `modelsStaged` existed) @@ -707,7 +734,7 @@ async function runLaunch( // with `boot_failed`); a healthy boot discards them — `boot_started` // is already ~258k/14d and per-phase emits on every boot would be pure // volume. See `bootPhaseBuffer`. - onPhaseEnter: (phase) => recordBootPhase(installationId, phase) + onPhaseEnter: (phase) => recordBootPhase(sessionId, phase) }) launchTracker.start() return launchTracker @@ -723,7 +750,7 @@ async function runLaunch( try { return await step() } catch (err) { - _cleanupFailedLaunchSetup(installationId, abort, opts) + _cleanupFailedLaunchSetup(sessionId, abort, opts) throw err } } @@ -909,7 +936,7 @@ async function runLaunch( inst, { sendProgress, - sendOutput: makeSendOutput(event.sender, installationId), + sendOutput: makeSendOutput(event.sender, sessionId), update: updateFn, signal: abort.signal }, @@ -1156,17 +1183,19 @@ async function runLaunch( } }) } catch (_err) { - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) if (abort.signal.aborted) return { ok: false, cancelled: true } return { ok: false, message: i18n.t('errors.cannotConnect', { url: displayUrl }) } } - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) - const mode = (inst.launchMode as string | undefined) || 'window' + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) + const mode = _resolveLaunchMode(inst, actionData) _addSession( - installationId, + sessionId, { proc: null, port: launchCmd.port!, url: launchCmd.url, mode, installationName: inst.name }, - Date.now() - launchStartedAt + Date.now() - launchStartedAt, + undefined, + installationId ) if (_onLaunch) { _onLaunch({ @@ -1182,27 +1211,27 @@ async function runLaunch( // Local process launch if (!fs.existsSync(launchCmd.cmd!)) { - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) return { ok: false, message: i18n.t('errors.executableNotFound', { cmd: launchCmd.cmd ?? '' }) } } // Skip port logic entirely if (launchCmd.skipPortWait) { - const sendOutput = makeSendOutput(sender, installationId) + const sendOutput = makeSendOutput(sender, sessionId) const launchEnv = buildLaunchEnv(inst) // Marked inside the guard: even the marker's renderer broadcast can // throw, and every throw after the marker exists must clear it before // the handler settles. const { logStream, execTap, hwTap, assetsTap, tracker } = await guardLaunchSetup(() => { - _markLaunching(installationId, inst.name) + _markLaunching(sessionId, inst.name) return acquireLaunchResources() }) // Last pre-spawn cancellation point on this path: a launch cancelled // during the awaits above must never spawn. if (abort.signal.aborted) { logStream.end() - _clearLaunchingFailed(installationId) + _clearLaunchingFailed(sessionId) return { ok: false, cancelled: true } } // Past the final gate: this launch is going to spawn, so the grants it applied are now @@ -1231,27 +1260,30 @@ async function runLaunch( { logStream } ) - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) - const mode = (inst.launchMode as string | undefined) || 'window' + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) + const mode = _resolveLaunchMode(inst, actionData) _addSession( - installationId, + sessionId, { proc, port: 0, mode, installationName: inst.name, + getAcceleratorInfo: () => hwTap.getAcceleratorInfo(), flushTelemetry: () => { execTap.flushSummary() hwTap.flushSummary() assetsTap.flushSummary() } }, - Date.now() - launchStartedAt + Date.now() - launchStartedAt, + undefined, + installationId ) onProcessTerminated(proc, async (code, signal) => { logStream.end() - const crashed = _runningSessions.has(installationId) && isCrashedExit(code, signal) + const crashed = _runningSessions.has(sessionId) && isCrashedExit(code, signal) // Raw stderr — this payload is shown to the user in the crashed-state // lifecycle UI. PII scrubbing happens on the telemetry path // (`scrubTelemetryContext` in renderer bootstrap), not here. @@ -1263,9 +1295,9 @@ async function runLaunch( // relaunch can't slip in and clearCrash() during the audit and have this // handler then resurrect the stale crash via recordCrash(). const crashDiagnosis = crashed ? await diagnoseCrash(code) : {} - _removeSession(installationId) + _removeSession(sessionId) const exitedPayload = { - installationId, + installationId: sessionId, crashed, exitCode: code ?? undefined, signal: signal ?? undefined, @@ -1290,7 +1322,7 @@ async function runLaunch( if (!sender.isDestroyed()) { sender.send('comfy-exited', exitedPayload) } - if (_onComfyExited) _onComfyExited({ installationId, crashed }) + if (_onComfyExited) _onComfyExited({ installationId: sessionId, crashed }) }) if (_onLaunch) { @@ -1304,13 +1336,11 @@ async function runLaunch( } const defaults = source.getDefaults ? source.getDefaults() : {} - const portConflictMode = - (inst.portConflict as string | undefined) || - (defaults.portConflict as string | undefined) || - 'auto' - const userArgs = ((inst.launchArgs as string | undefined) || '').trim() - const portIsExplicit = - actionData?.portOverride != null || /(?:^|\s)--port(?:\s|=|$)/.test(userArgs) + const { mode: portConflictMode, portIsExplicit } = _resolvePortConflictPolicy( + inst, + defaults, + actionData + ) // isPortListening (bind test) is the primary check; findPidsByPort's lsof // only sees same-user processes on Linux. @@ -1370,7 +1400,7 @@ async function runLaunch( }) } } - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) return { ok: false, message, @@ -1400,7 +1430,7 @@ async function runLaunch( }) setPortArg(launchCmd as LaunchCmd, nextPort) } else { - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) return { ok: false, message: i18n.t('errors.portConflictLauncher', { @@ -1420,7 +1450,7 @@ async function runLaunch( // throw here has nothing to clean up yet. const sessionPath = createSessionPath() const launchEnv = buildLaunchEnv(inst, sessionPath) - const sendOutput = makeSendOutput(sender, installationId) + const sendOutput = makeSendOutput(sender, sessionId) // Port reservation and launching marker sit INSIDE the guard: even the // marker's renderer broadcast can throw, and every throw after either @@ -1429,7 +1459,7 @@ async function runLaunch( const { logStream, execTap, hwTap, assetsTap, tracker } = await guardLaunchSetup( () => { _reservePort(launchCmd.port!, inst.name) - _markLaunching(installationId, inst.name) + _markLaunching(sessionId, inst.name) return acquireLaunchResources() }, { port: launchCmd.port! } @@ -1499,9 +1529,9 @@ async function runLaunch( .join(' ') sendProgress('launch', { percent: -1, status: i18n.t('launch.starting') }) if (!sender.isDestroyed()) { - sender.send('comfy-output', { installationId, text: `> ${cmdLine}\n\n` }) + sender.send('comfy-output', { installationId: sessionId, text: `> ${cmdLine}\n\n` }) } - appendLog(installationId, `> ${cmdLine}\n\n`) + appendLog(sessionId, `> ${cmdLine}\n\n`) // Explicit boot-attempt event. `installation_started` already fires // on successful boot with `boot_time_ms`, and `comfyui.exited` carries // `crashed=true` on failure — but boot success rate needed inferred @@ -1519,7 +1549,7 @@ async function runLaunch( // reboot retry this resets so the buffer reflects the attempt that // actually fails (or succeeds). The tracker's `onPhaseEnter` feeds it; // it is flushed only on the terminal failure path below. - startBootPhases(installationId, (inst.variant as string | undefined) ?? null) + startBootPhases(sessionId, (inst.variant as string | undefined) ?? null) // Re-arm per-attempt phase observation: the UI tracker's index is // monotonic across retries, so without this the respawned boot's re-hit // milestones would never reach the fresh buffer above. @@ -1656,9 +1686,9 @@ async function runLaunch( logStream.end() _releasePort(launchCmd.port!) // Ownership-guarded: never evict a slot a newer operation already claimed. - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) abort.abort() // stop the template-models reader timer on launch failure - _clearLaunchingFailed(installationId) + _clearLaunchingFailed(sessionId) // Flush the hardware tap on terminal failure/cancel too: the exit handler // covers a process that exits, but a waitForPort timeout can return here // with the proc still alive, leaving a pending accelerator event unemitted. @@ -1668,7 +1698,7 @@ async function runLaunch( if (launchResult.cancelled) { // User-initiated cancel is not a boot failure — discard the buffer so a // later relaunch starts clean and we don't emit phantom boot_phase rows. - clearBootPhases(installationId) + clearBootPhases(sessionId) return { ok: false, cancelled: true } } execTap.flushSummary() @@ -1678,7 +1708,7 @@ async function runLaunch( // emit the paired boot_failed. `failed_phase` is the last phase the boot // reached (null if it never entered one). The error is bucketed; the // retry counters surface how many times we re-spawned before giving up. - const failedPhase = flushBootPhasesOnFailure(installationId) + const failedPhase = flushBootPhasesOnFailure(sessionId) // Standard error schema derived from the failure message + the stderr // tail (a Python traceback in the tail yields a real `error_class` / // `error_message`; otherwise the launch message drives it). `error_tail` @@ -1706,20 +1736,21 @@ async function runLaunch( } // Healthy boot — discard buffered phase timings (no boot_phase on success; // healthy timing is covered by instance_started.boot_time_ms). - clearBootPhases(installationId) + clearBootPhases(sessionId) let { proc } = launchResult _pendingPorts.delete(launchCmd.port!) - if (_operationAborts.get(installationId) === abort) _operationAborts.delete(installationId) - const mode = (inst.launchMode as string | undefined) || 'window' + if (_operationAborts.get(sessionId) === abort) _operationAborts.delete(sessionId) + const mode = _resolveLaunchMode(inst, actionData) const bootTimeMs = Date.now() - launchStartedAt _addSession( - installationId, + sessionId, { proc, port: launchCmd.port!, mode, installationName: inst.name, + getAcceleratorInfo: () => hwTap.getAcceleratorInfo(), flushTelemetry: () => { execTap.flushSummary() hwTap.flushSummary() @@ -1727,7 +1758,8 @@ async function runLaunch( } }, bootTimeMs, - { portRetries, rebootRetries } + { portRetries, rebootRetries }, + installationId ) // Paired success terminal for boot_started: server up + session registered. // Same boot_id as this launch's boot_started(s), so the boot-success rate is @@ -1745,7 +1777,7 @@ async function runLaunch( if (!sender.isDestroyed()) { // Raw bootStderr — telemetry forwarders scrub it before it leaves the box. const bootStderr = lastNLines(launchResult.getStderr(), 50) - sender.send('comfy-boot-log', { installationId, bootStderr }) + sender.send('comfy-boot-log', { installationId: sessionId, bootStderr }) } // Capture snapshot in background after successful launch @@ -1772,12 +1804,12 @@ async function runLaunch( if (newFolders.length > 0) { sendOutput(`\n--- Restarting: new model folders detected (${newFolders.join(', ')}) ---\n\n`) if (_onModelFolderRelaunch) { - await Promise.resolve(_onModelFolderRelaunch({ installationId })).catch(() => {}) + await Promise.resolve(_onModelFolderRelaunch({ installationId: sessionId })).catch(() => {}) } await killProcessTree(proc) const respawned = await spawnComfy() proc = respawned.proc - const session = _runningSessions.get(installationId) + const session = _runningSessions.get(sessionId) if (session) session.proc = proc writePortLock(launchCmd.port!, { pid: proc.pid!, installationName: inst.name }) const relaunchEarlyExit = new Promise((_resolve, reject) => { @@ -1806,8 +1838,8 @@ async function runLaunch( execTap.flushSummary() hwTap.flushSummary() assetsTap.flushSummary() - _removeSession(installationId) - _clearLaunchingFailed(installationId) + _removeSession(sessionId) + _clearLaunchingFailed(sessionId) if (abort.signal.aborted) return { ok: false, cancelled: true } return { ok: false, message: (err as Error).message } } @@ -1857,11 +1889,11 @@ async function runLaunch( const spawned = await spawnComfy() proc = spawned.proc currentGetStderr = spawned.getStderr - const session = _runningSessions.get(installationId) + const session = _runningSessions.get(sessionId) if (session) session.proc = proc writePortLock(launchCmd.port!, { pid: proc.pid!, installationName: inst.name }) attachExitHandler(proc) - if (_onComfyRestarted) _onComfyRestarted({ installationId, process: proc }) + if (_onComfyRestarted) _onComfyRestarted({ installationId: sessionId, process: proc }) if (manageModelFolders) { rebootModelCheckAbort = new AbortController() const checkSignal = rebootModelCheckAbort.signal @@ -1871,7 +1903,7 @@ async function runLaunch( }) .then(async () => { if (checkSignal.aborted) return - const currentSession = _runningSessions.get(installationId) + const currentSession = _runningSessions.get(sessionId) if (!currentSession || currentSession.proc !== proc) return const currentExtras = discoverExtraModelFolders(inst.installPath) const newFolders = currentExtras.filter((f) => !knownExtras.has(f)) @@ -1891,7 +1923,9 @@ async function runLaunch( ) pendingModelFolderRelaunch = true if (_onModelFolderRelaunch) { - await Promise.resolve(_onModelFolderRelaunch({ installationId })).catch(() => {}) + await Promise.resolve( + _onModelFolderRelaunch({ installationId: sessionId }) + ).catch(() => {}) } killProcessTree(proc) } @@ -1915,7 +1949,7 @@ async function runLaunch( return } logStream.end() - const crashed = _runningSessions.has(installationId) && isCrashedExit(code, signal) + const crashed = _runningSessions.has(sessionId) && isCrashedExit(code, signal) // Raw stderr — see note in the early-fail exit handler above. const lastStderr = lastNLines(currentGetStderr(), 100) execTap.flushSummary() @@ -1925,9 +1959,9 @@ async function runLaunch( // relaunch can't slip in and clearCrash() during the audit and have this // handler then resurrect the stale crash via recordCrash(). const crashDiagnosis = crashed ? await diagnoseCrash(code) : {} - _removeSession(installationId) + _removeSession(sessionId) const exitedPayload = { - installationId, + installationId: sessionId, crashed, exitCode: code ?? undefined, signal: signal ?? undefined, @@ -1952,7 +1986,7 @@ async function runLaunch( if (!sender.isDestroyed()) { sender.send('comfy-exited', exitedPayload) } - if (_onComfyExited) _onComfyExited({ installationId, crashed }) + if (_onComfyExited) _onComfyExited({ installationId: sessionId, crashed }) }) } attachExitHandler(proc) diff --git a/src/main/lib/ipc/sessionActions/types.ts b/src/main/lib/ipc/sessionActions/types.ts index 283195c1c..47d1ca754 100644 --- a/src/main/lib/ipc/sessionActions/types.ts +++ b/src/main/lib/ipc/sessionActions/types.ts @@ -3,6 +3,8 @@ import type { InstallationRecord } from '../shared' export interface ActionContext { event: Electron.IpcMainInvokeEvent installationId: string + /** Runtime identity when one installation owns an isolated secondary session. */ + sessionId?: string inst: InstallationRecord actionData?: Record } diff --git a/src/main/lib/ipc/shared.ts b/src/main/lib/ipc/shared.ts index e9fc48c1a..27d726efb 100644 --- a/src/main/lib/ipc/shared.ts +++ b/src/main/lib/ipc/shared.ts @@ -36,6 +36,7 @@ import { _broadcastToRenderer } from './broadcast' import { appendLog } from '../logsBroadcast' import { flushOperationOutput } from '../appLog' import { stripAnsi } from '../stderrTail' +import type { AcceleratorSnapshot } from '../hardwareTap' import { spawnProcess, waitForPort, @@ -277,9 +278,12 @@ export interface SessionInfo { url?: string mode: string installationName: string + sourceInstallationId?: string startedAt: number /** Synchronously queue final telemetry before app-level shutdown drains the SDK. */ flushTelemetry?: () => void + /** Latest accelerator details parsed from this session's ComfyUI startup logs. */ + getAcceleratorInfo?: () => AcceleratorSnapshot | null } export interface LaunchCallbackInfo { @@ -1051,13 +1055,23 @@ export { export function _addSession( installationId: string, - { proc, port, url, mode, installationName, flushTelemetry }: Omit, + { + proc, + port, + url, + mode, + installationName, + flushTelemetry, + getAcceleratorInfo + }: Omit, bootTimeMs?: number, /** Spawn-retry counts for THIS boot, folded onto the broadcast so the * renderer's `instance_started` telemetry can carry them without a * separate `server_ready` event. Omitted for the remote / skip-port paths * (no spawn retry there). */ - retries?: { portRetries: number; rebootRetries: number } + retries?: { portRetries: number; rebootRetries: number }, + /** Durable installation identity when the runtime session uses an isolated key. */ + sourceInstallationId: string = installationId ): void { _runningSessions.set(installationId, { proc, @@ -1065,7 +1079,9 @@ export function _addSession( url, mode, installationName, + sourceInstallationId, flushTelemetry, + getAcceleratorInfo, startedAt: Date.now() }) // Clear the launching marker first so subscribers never double-count this id across the @@ -1087,7 +1103,7 @@ export function _addSession( // callback could fire. Fire-and-forget; never blocks the launch. if (_onInstanceStarted) { _onInstanceStarted({ - installationId, + installationId: sourceInstallationId, bootTimeMs, portRetries: retries?.portRetries ?? 0, rebootRetries: retries?.rebootRetries ?? 0 @@ -1095,7 +1111,7 @@ export function _addSession( } // Stamps lastLaunchedAt + per-category recency so those surfaces needn't scan every record. installations - .markLaunched(installationId, (inst) => sourceMap[inst.sourceId]?.category) + .markLaunched(sourceInstallationId, (inst) => sourceMap[inst.sourceId]?.category) .then(() => _broadcastToRenderer('installations-changed', {})) .catch((err) => { console.error('Failed to mark installation launched:', err) @@ -1126,6 +1142,14 @@ export function _getPublicSessions(): Record[] { })) } +export function hasRunningSessionForInstallation(installationId: string): boolean { + return Array.from( + _runningSessions, + ([sessionId, session]) => + sessionId === installationId || session.sourceInstallationId === installationId + ).some(Boolean) +} + /** * Build the installation snapshot/disk context for the * `get-installation-dd-context` IPC handler and the main-process diff --git a/src/main/lib/performanceTestWorkflows.test.ts b/src/main/lib/performanceTestWorkflows.test.ts new file mode 100644 index 000000000..5dae26aaa --- /dev/null +++ b/src/main/lib/performanceTestWorkflows.test.ts @@ -0,0 +1,675 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + calculatePerformanceTestStatistics, + deletePerformanceTestBenchmark, + deletePerformanceTestWorkflow, + incrementWorkflowSeeds, + listPerformanceTestBenchmarks, + renamePerformanceTestBenchmark, + readPerformanceTestResultsSummary, + savePerformanceTestJobsResponse, + savePerformanceTestLogs, + savePerformanceTestResultsSummary, + storePerformanceTestWorkflow, + submitPerformanceTestWorkflow, + waitForPerformanceTestJobs +} from './performanceTestWorkflows' + +describe('calculatePerformanceTestStatistics', () => { + it('calculates fastest, slowest, average, and median for measured jobs only', () => { + const response = { + jobs: [ + { + id: 'measured-3', + status: 'completed', + execution_start_time: 30000, + execution_end_time: 39000 + }, + { + id: 'warmup', + status: 'completed', + execution_start_time: 0, + execution_end_time: 100000 + }, + { + id: 'measured-1', + status: 'completed', + execution_start_time: 10000, + execution_end_time: 12000 + }, + { + id: 'measured-4', + status: 'completed', + execution_start_time: 40000, + execution_end_time: 44000 + }, + { + id: 'measured-2', + status: 'completed', + execution_start_time: 20000, + execution_end_time: 26000 + }, + { + id: 'measured-failed', + status: 'failed', + execution_start_time: 50000, + execution_end_time: 150000 + } + ] + } + + expect( + calculatePerformanceTestStatistics(response, [ + 'measured-1', + 'measured-2', + 'measured-3', + 'measured-4', + 'measured-failed' + ]) + ).toEqual({ + fastest: { jobId: 'measured-1', durationSeconds: 2 }, + slowest: { jobId: 'measured-3', durationSeconds: 9 }, + averageDurationSeconds: 5.25, + medianDurationSeconds: 5, + measuredJobCount: 4 + }) + }) + + it('ignores jobs without valid timestamps and returns null when none are measurable', () => { + expect( + calculatePerformanceTestStatistics( + { + jobs: [ + { id: 'missing-end', status: 'failed', execution_start_time: 10 }, + { + id: 'backwards', + status: 'completed', + execution_start_time: 10, + execution_end_time: 5 + } + ] + }, + ['missing-end', 'backwards'] + ) + ).toBeNull() + }) +}) + +const tempDirs: string[] = [] + +async function makeTempDir(): Promise { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'comfy-performance-test-workflow-')) + tempDirs.push(dir) + return dir +} + +function benchmarkSummary(overrides: Record = {}): Record { + return { + instance: { id: 'instance-1', name: 'Comfy' }, + workspace: { id: null, name: null }, + workflowName: 'workflow.json', + fastestJobDurationSeconds: 1, + slowestJobDurationSeconds: 2, + averageJobDurationSeconds: 1.5, + medianJobDurationSeconds: 1.5, + measuredJobCount: 2, + ...overrides + } +} + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => + fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 }) + ) + ) +}) + +describe('listPerformanceTestBenchmarks', () => { + it('returns valid summaries newest first and ignores incomplete or malformed sessions', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const testsPath = path.join(userDataPath, 'performance-tests') + const validSummary = { + instance: { id: 'instance-1', name: 'Comfy One' }, + workspace: { id: 'workspace-1', name: 'Design' }, + workflowName: 'portrait.json', + fastestJobDurationSeconds: 1.25, + slowestJobDurationSeconds: 3.5, + averageJobDurationSeconds: 2.125, + medianJobDurationSeconds: 2, + measuredJobCount: 5, + hardware: { deviceName: 'NVIDIA RTX 4090' }, + customMetric: 42 + } + await Promise.all( + ['original-session', 'renamed session', 'malformed-session', 'incomplete-session'].map((id) => + fs.promises.mkdir(path.join(testsPath, id), { recursive: true }) + ) + ) + await fs.promises.writeFile( + path.join(testsPath, 'original-session', 'results.json'), + JSON.stringify(validSummary) + ) + await fs.promises.writeFile( + path.join(testsPath, 'renamed session', 'results.json'), + JSON.stringify({ + ...validSummary, + createdAt: '2026-09-13T15:30:45.000Z', + instance: { id: 'instance-2', name: 'Comfy Two' }, + workspace: { id: null, name: null }, + workflowName: 'product.json', + hardware: { deviceType: 'mps' } + }) + ) + await fs.promises.writeFile( + path.join(testsPath, 'malformed-session', 'results.json'), + '{ malformed' + ) + + const benchmarks = await listPerformanceTestBenchmarks(testsPath) + + expect(benchmarks.map((benchmark) => benchmark.id)).toEqual([ + 'renamed session', + 'original-session' + ]) + expect(benchmarks[0]).toEqual({ + id: 'renamed session', + createdAt: '2026-09-13T15:30:45.000Z', + instance: { id: 'instance-2', name: 'Comfy Two' }, + workspace: { id: null, name: null }, + workflowName: 'product.json', + fastestJobDurationSeconds: 1.25, + slowestJobDurationSeconds: 3.5, + averageJobDurationSeconds: 2.125, + medianJobDurationSeconds: 2, + measuredJobCount: 5, + hardwareName: 'mps', + result: { + ...validSummary, + createdAt: '2026-09-13T15:30:45.000Z', + instance: { id: 'instance-2', name: 'Comfy Two' }, + workspace: { id: null, name: null }, + workflowName: 'product.json', + hardware: { deviceType: 'mps' } + } + }) + expect(benchmarks[1]!.createdAt).toBeNull() + }) + + it('returns an empty list before any performance tests have been saved', async () => { + const root = await makeTempDir() + + await expect( + listPerformanceTestBenchmarks(path.join(root, 'user-data', 'performance-tests')) + ).resolves.toEqual([]) + }) +}) + +describe('deletePerformanceTestBenchmark', () => { + it('deletes the complete validated session directory without allowing path traversal', async () => { + const root = await makeTempDir() + const testsPath = path.join(root, 'performance-tests') + const sessionPath = path.join(testsPath, 'session-1') + const siblingPath = path.join(root, 'keep-me') + await fs.promises.mkdir(sessionPath, { recursive: true }) + await fs.promises.mkdir(siblingPath) + await fs.promises.writeFile(path.join(sessionPath, 'workflow.json'), '{}') + await fs.promises.writeFile(path.join(sessionPath, 'jobs.json'), '{}') + await fs.promises.writeFile( + path.join(sessionPath, 'results.json'), + JSON.stringify(benchmarkSummary()) + ) + + await expect(deletePerformanceTestBenchmark(testsPath, '../keep-me')).rejects.toThrow( + 'Invalid performance test benchmark ID.' + ) + await expect(deletePerformanceTestBenchmark(testsPath, '.')).rejects.toThrow( + 'Invalid performance test benchmark ID.' + ) + await expect(deletePerformanceTestBenchmark(testsPath, '..')).rejects.toThrow( + 'Invalid performance test benchmark ID.' + ) + await expect(fs.promises.stat(siblingPath)).resolves.toBeDefined() + + await deletePerformanceTestBenchmark(testsPath, 'session-1') + await expect(fs.promises.stat(sessionPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) + +describe('renamePerformanceTestBenchmark', () => { + it('renames the complete validated session directory', async () => { + const root = await makeTempDir() + const testsPath = path.join(root, 'performance-tests') + const sessionPath = path.join(testsPath, 'session-1') + const renamedPath = path.join(testsPath, 'my-session') + await fs.promises.mkdir(sessionPath, { recursive: true }) + await fs.promises.writeFile(path.join(sessionPath, 'workflow.json'), '{}') + await fs.promises.writeFile( + path.join(sessionPath, 'results.json'), + JSON.stringify(benchmarkSummary()) + ) + + await renamePerformanceTestBenchmark(testsPath, 'session-1', 'my-session') + + await expect(fs.promises.stat(sessionPath)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect( + fs.promises.readFile(path.join(renamedPath, 'workflow.json'), 'utf8') + ).resolves.toBe('{}') + await expect(listPerformanceTestBenchmarks(testsPath)).resolves.toMatchObject([ + { id: 'my-session' } + ]) + }) + + it('rejects invalid or existing destination names', async () => { + const root = await makeTempDir() + const testsPath = path.join(root, 'performance-tests') + const sessionPath = path.join(testsPath, 'session-1') + await fs.promises.mkdir(sessionPath, { recursive: true }) + await fs.promises.mkdir(path.join(testsPath, 'existing')) + await fs.promises.writeFile( + path.join(sessionPath, 'results.json'), + JSON.stringify(benchmarkSummary()) + ) + + await expect(renamePerformanceTestBenchmark(testsPath, 'missing', 'missing')).rejects.toThrow() + await expect(renamePerformanceTestBenchmark(testsPath, 'session-1', '..')).rejects.toThrow( + 'Invalid performance test benchmark ID.' + ) + await expect( + renamePerformanceTestBenchmark(testsPath, 'session-1', 'existing') + ).rejects.toThrow('A benchmark session with that name already exists.') + await expect(fs.promises.stat(sessionPath)).resolves.toBeDefined() + }) +}) + +describe('storePerformanceTestWorkflow', () => { + it('copies an API-format workflow into the app user-data directory', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'performanceTest.json') + const contents = JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + await fs.promises.writeFile(sourcePath, contents) + + const storedPath = await storePerformanceTestWorkflow(sourcePath, path.join(root, 'user-data')) + + expect(path.dirname(path.dirname(storedPath))).toBe( + path.join(root, 'user-data', 'performance-tests') + ) + expect(path.basename(path.dirname(storedPath))).toMatch(/^\d{14}$/) + expect(path.basename(storedPath)).toBe('performanceTest.json') + expect(await fs.promises.readFile(storedPath, 'utf8')).toBe(contents) + expect(await fs.promises.readFile(sourcePath, 'utf8')).toBe(contents) + }) + + it('creates a unique timestamped session directory for each workflow', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + + const firstPath = await storePerformanceTestWorkflow(sourcePath, path.join(root, 'user-data')) + const secondPath = await storePerformanceTestWorkflow(sourcePath, path.join(root, 'user-data')) + + expect(path.basename(secondPath)).toBe('performanceTest.json') + expect(path.basename(path.dirname(secondPath))).toMatch(/^\d{14}$/) + expect(path.dirname(secondPath)).not.toBe(path.dirname(firstPath)) + }) + + it.each(['jobs.json', 'results.json'])( + 'reserves %s for performance test output', + async (name) => { + const root = await makeTempDir() + const sourcePath = path.join(root, name) + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + + await expect( + storePerformanceTestWorkflow(sourcePath, path.join(root, 'user-data')) + ).rejects.toThrow(`${name} is reserved`) + } + ) + + it('rejects JSON that is not a ComfyUI API-format workflow', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'editor-workflow.json') + await fs.promises.writeFile(sourcePath, JSON.stringify({ nodes: [], links: [] })) + + await expect( + storePerformanceTestWorkflow(sourcePath, path.join(root, 'user-data')) + ).rejects.toThrow('not a ComfyUI API-format workflow') + }) +}) + +describe('deletePerformanceTestWorkflow', () => { + it('deletes a managed performance test workflow copy', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + + await deletePerformanceTestWorkflow(storedPath, userDataPath) + + await expect(fs.promises.stat(storedPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('refuses to delete files outside the managed directory', async () => { + const root = await makeTempDir() + const sourcePath = path.join(root, 'keep.json') + await fs.promises.writeFile(sourcePath, '{}') + + await expect( + deletePerformanceTestWorkflow(sourcePath, path.join(root, 'user-data')) + ).rejects.toThrow('outside a managed performance test session directory') + expect(await fs.promises.readFile(sourcePath, 'utf8')).toBe('{}') + }) + + it.each(['jobs.json', 'results.json', 'logs.txt'])( + 'preserves a completed session with %s when clearing its workflow from the page', + async (outputName) => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + const outputPath = path.join(path.dirname(storedPath), outputName) + await fs.promises.writeFile(outputPath, '{}') + + await deletePerformanceTestWorkflow(storedPath, userDataPath) + + await expect(fs.promises.stat(storedPath)).resolves.toBeDefined() + await expect(fs.promises.stat(outputPath)).resolves.toBeDefined() + } + ) +}) + +describe('submitPerformanceTestWorkflow', () => { + it('posts warm-up requests before the measured runs with incremented seeds', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + const workflow = { '1': { class_type: 'KSampler', inputs: { seed: 1 } } } + await fs.promises.writeFile(sourcePath, JSON.stringify(workflow)) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + let requestCount = 0 + const fetchMock = vi.fn(async () => { + requestCount++ + return new Response(JSON.stringify({ prompt_id: `prompt-${requestCount}` })) + }) + + const promptIds = await submitPerformanceTestWorkflow( + storedPath, + userDataPath, + 'http://127.0.0.1:8189/base', + 3, + 2, + fetchMock + ) + + expect(promptIds).toEqual(['prompt-1', 'prompt-2', 'prompt-3', 'prompt-4', 'prompt-5']) + expect(fetchMock).toHaveBeenCalledTimes(5) + for (const [index, [requestUrl, requestInit]] of fetchMock.mock.calls.entries()) { + expect(String(requestUrl)).toBe('http://127.0.0.1:8189/prompt') + expect(requestInit).toMatchObject({ + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + expect(JSON.parse(String(requestInit?.body))).toEqual({ + prompt: { '1': { class_type: 'KSampler', inputs: { seed: index + 2 } } } + }) + } + expect(JSON.parse(await fs.promises.readFile(storedPath, 'utf8'))).toEqual(workflow) + }) + + it('stops submitting when ComfyUI rejects a request', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ prompt_id: 'prompt-1' }))) + .mockResolvedValueOnce(new Response('invalid workflow', { status: 400 })) + + await expect( + submitPerformanceTestWorkflow( + storedPath, + userDataPath, + 'http://127.0.0.1:8189', + 3, + 1, + fetchMock + ) + ).rejects.toThrow('Performance Test request 2 failed: 400') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('rejects a successful response without a prompt ID', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + const fetchMock = vi.fn(async () => new Response('{}')) + + await expect( + submitPerformanceTestWorkflow( + storedPath, + userDataPath, + 'http://127.0.0.1:8189', + 1, + 1, + fetchMock + ) + ).rejects.toThrow('did not return a prompt ID') + }) +}) + +describe('waitForPerformanceTestJobs', () => { + it('polls the jobs collection until every submitted prompt is terminal', async () => { + const pendingResponse = { + jobs: [ + { id: 'warmup-1', status: 'completed' }, + { id: 'measured-1', status: 'completed' }, + { id: 'measured-2', status: 'in_progress' }, + { id: 'unrelated', status: 'pending' } + ] + } + const terminalResponse = { + jobs: [ + { id: 'warmup-1', status: 'completed' }, + { id: 'measured-1', status: 'completed' }, + { id: 'measured-2', status: 'failed', execution_error: { message: 'failed' } } + ], + pagination: { total: 4, has_more: false } + } + const terminalApiResponse = { + ...terminalResponse, + jobs: [...terminalResponse.jobs, { id: 'unrelated', status: 'completed' }] + } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(pendingResponse))) + .mockResolvedValueOnce(new Response(JSON.stringify(terminalApiResponse))) + const onProgress = vi.fn() + + await expect( + waitForPerformanceTestJobs( + 'http://127.0.0.1:8189/base', + ['warmup-1', 'measured-1', 'measured-2'], + fetchMock, + 0, + onProgress + ) + ).resolves.toEqual(terminalResponse) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(String(fetchMock.mock.calls[0]![0])).toBe('http://127.0.0.1:8189/api/jobs?limit=3') + expect(onProgress.mock.calls).toEqual([ + [2, 3], + [3, 3] + ]) + }) +}) + +describe('savePerformanceTestJobsResponse', () => { + it('writes the final jobs response beside the session workflow', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + const response = { jobs: [{ id: 'prompt-1', status: 'completed' }] } + + const resultPath = await savePerformanceTestJobsResponse(response, storedPath, userDataPath) + + expect(resultPath).toBe(path.join(path.dirname(storedPath), 'jobs.json')) + expect(JSON.parse(await fs.promises.readFile(resultPath, 'utf8'))).toEqual(response) + }) +}) + +describe('savePerformanceTestLogs', () => { + it('writes the displayed instance logs beside the session workflow', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + + const logsPath = await savePerformanceTestLogs( + 'launching\ncompleted\n', + storedPath, + userDataPath + ) + + expect(logsPath).toBe(path.join(path.dirname(storedPath), 'logs.txt')) + await expect(fs.promises.readFile(logsPath, 'utf8')).resolves.toBe('launching\ncompleted\n') + }) +}) + +describe('savePerformanceTestResultsSummary', () => { + it('writes every value needed by the image export beside the raw results', async () => { + const root = await makeTempDir() + const userDataPath = path.join(root, 'user-data') + const sourcePath = path.join(root, 'performanceTest.json') + await fs.promises.writeFile( + sourcePath, + JSON.stringify({ '1': { class_type: 'KSampler', inputs: {} } }) + ) + const storedPath = await storePerformanceTestWorkflow(sourcePath, userDataPath) + + const hardware = { + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native', + devices: [], + vramMb: 24576, + ramMb: 65536, + pytorchVersion: '2.10.0+cu130', + xformersVersion: '0.0.31', + cudaDeviceSet: 0 + } + const systemInfo = { + cpu_model: 'AMD Ryzen 9 7950X', + cpu_cores: 32, + arch: 'x64', + platform: 'win32', + os_version: '10.0.26200', + os_distro: 'Microsoft Windows 11 Pro', + os_release: '10.0.26200' + } as Parameters[4] + const summaryPath = await savePerformanceTestResultsSummary( + { + fastest: { jobId: 'job-1', durationSeconds: 1.25 }, + slowest: { jobId: 'job-2', durationSeconds: 2.75 }, + averageDurationSeconds: 2, + medianDurationSeconds: 1.875, + measuredJobCount: 2 + }, + { id: 'local-instance', name: 'Local Instance' }, + { id: 'workspace-2', name: 'Workspace Two' }, + hardware, + systemInfo, + storedPath, + userDataPath, + 2, + 1 + ) + + expect(summaryPath).toBe(path.join(path.dirname(storedPath), 'results.json')) + const savedSummary = JSON.parse(await fs.promises.readFile(summaryPath, 'utf8')) + expect(savedSummary).toEqual({ + createdAt: expect.any(String), + instance: { id: 'local-instance', name: 'Local Instance' }, + workspace: { id: 'workspace-2', name: 'Workspace Two' }, + workflowName: 'performanceTest.json', + fastestJobDurationSeconds: 1.25, + slowestJobDurationSeconds: 2.75, + averageJobDurationSeconds: 2, + medianJobDurationSeconds: 1.875, + measuredJobCount: 2, + failedRunCount: 1, + hardware, + systemInfo + }) + expect(Number.isFinite(Date.parse(savedSummary.createdAt))).toBe(true) + + savedSummary.workflowName = 'renamed-after-test.json' + await fs.promises.writeFile(summaryPath, JSON.stringify(savedSummary)) + await expect(readPerformanceTestResultsSummary(summaryPath, userDataPath)).resolves.toEqual( + expect.objectContaining({ workflowName: 'renamed-after-test.json' }) + ) + }) +}) + +describe('incrementWorkflowSeeds', () => { + it('increments all numeric seed-like inputs without mutating the source workflow', () => { + const workflow = { + sampler: { + class_type: 'KSampler', + inputs: { seed: 10, noise_seed: 20, seed_mode: 'fixed', cfg: 7 } + }, + linked: { class_type: 'Sampler', inputs: { seed: ['primitive', 0] } } + } + + expect(incrementWorkflowSeeds(workflow)).toEqual({ + sampler: { + class_type: 'KSampler', + inputs: { seed: 11, noise_seed: 21, seed_mode: 'fixed', cfg: 7 } + }, + linked: { class_type: 'Sampler', inputs: { seed: ['primitive', 0] } } + }) + expect(workflow.sampler.inputs.seed).toBe(10) + expect(workflow.sampler.inputs.noise_seed).toBe(20) + }) +}) diff --git a/src/main/lib/performanceTestWorkflows.ts b/src/main/lib/performanceTestWorkflows.ts new file mode 100644 index 000000000..423f9817e --- /dev/null +++ b/src/main/lib/performanceTestWorkflows.ts @@ -0,0 +1,572 @@ +import fs from 'fs' +import path from 'path' +import type { AcceleratorSnapshot } from './hardwareTap' +import type { + PerformanceTestBenchmark, + PerformanceTestResultValue, + PerformanceTestResultsSummary, + SystemInfo +} from '../../types/ipc' + +const PERFORMANCE_TESTS_DIR = 'performance-tests' +const PERFORMANCE_TEST_POLL_INTERVAL_MS = 1000 + +const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']) + +export interface PerformanceTestJob { + id: string + status: string + [key: string]: unknown +} + +export interface PerformanceTestJobsResponse { + jobs: PerformanceTestJob[] + pagination?: unknown + [key: string]: unknown +} + +export interface PerformanceTestDurationResult { + jobId: string + durationSeconds: number +} + +export interface PerformanceTestStatistics { + fastest: PerformanceTestDurationResult + slowest: PerformanceTestDurationResult + averageDurationSeconds: number + medianDurationSeconds: number + measuredJobCount: number +} + +function isDuration(value: unknown): value is number | null { + return value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0) +} + +function parsePerformanceTestBenchmark( + value: unknown, + id: string +): PerformanceTestBenchmark | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const summary = value as Partial + if ( + !summary.instance || + typeof summary.instance.id !== 'string' || + typeof summary.instance.name !== 'string' || + !summary.workspace || + (summary.workspace.id !== null && typeof summary.workspace.id !== 'string') || + (summary.workspace.name !== null && typeof summary.workspace.name !== 'string') || + (summary.createdAt !== undefined && + (typeof summary.createdAt !== 'string' || Number.isNaN(Date.parse(summary.createdAt)))) || + typeof summary.workflowName !== 'string' || + !isDuration(summary.fastestJobDurationSeconds) || + !isDuration(summary.slowestJobDurationSeconds) || + !isDuration(summary.averageJobDurationSeconds) || + !isDuration(summary.medianJobDurationSeconds) || + !Number.isInteger(summary.measuredJobCount) || + summary.measuredJobCount! < 0 || + (summary.failedRunCount !== undefined && + (!Number.isInteger(summary.failedRunCount) || summary.failedRunCount < 0)) + ) { + return null + } + const hardware = summary.hardware + const hardwareName = + hardware && typeof hardware.deviceName === 'string' + ? hardware.deviceName + : hardware && typeof hardware.deviceType === 'string' + ? hardware.deviceType + : null + return { + id, + createdAt: summary.createdAt ?? null, + instance: summary.instance, + workspace: summary.workspace, + workflowName: summary.workflowName, + fastestJobDurationSeconds: summary.fastestJobDurationSeconds, + slowestJobDurationSeconds: summary.slowestJobDurationSeconds, + averageJobDurationSeconds: summary.averageJobDurationSeconds, + medianJobDurationSeconds: summary.medianJobDurationSeconds, + measuredJobCount: summary.measuredJobCount!, + hardwareName, + result: value as Record + } +} + +function resolvePerformanceTestSessionDir(performanceTestsDir: string, sessionId: string): string { + const testsDir = path.resolve(performanceTestsDir) + const sessionDir = path.resolve(testsDir, sessionId) + if ( + !sessionId || + path.dirname(sessionDir) !== testsDir || + path.basename(sessionDir) !== sessionId + ) { + throw new Error('Invalid performance test benchmark ID.') + } + return sessionDir +} + +async function readPerformanceTestBenchmark( + performanceTestsDir: string, + sessionId: string +): Promise { + const sessionDir = resolvePerformanceTestSessionDir(performanceTestsDir, sessionId) + const contents = await fs.promises.readFile(path.join(sessionDir, 'results.json'), 'utf8') + return parsePerformanceTestBenchmark(JSON.parse(contents) as unknown, sessionId) +} + +/** List valid completed performance test summaries, newest first. */ +export async function listPerformanceTestBenchmarks( + performanceTestsDir: string +): Promise { + let entries: fs.Dirent[] + try { + entries = await fs.promises.readdir(performanceTestsDir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + + const benchmarks: PerformanceTestBenchmark[] = [] + const sessions = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + + for (const session of sessions) { + try { + const benchmark = await readPerformanceTestBenchmark(performanceTestsDir, session) + if (benchmark) benchmarks.push(benchmark) + } catch { + // Missing or malformed sessions are ignored without hiding valid results. + } + } + return benchmarks.sort((a, b) => { + if (a.createdAt && b.createdAt) return b.createdAt.localeCompare(a.createdAt) + if (a.createdAt) return -1 + if (b.createdAt) return 1 + return b.id.localeCompare(a.id) + }) +} + +/** Delete a completed benchmark session and all files stored with it. */ +export async function deletePerformanceTestBenchmark( + performanceTestsDir: string, + sessionId: string +): Promise { + const sessionDir = resolvePerformanceTestSessionDir(performanceTestsDir, sessionId) + if (!(await readPerformanceTestBenchmark(performanceTestsDir, sessionId))) { + throw new Error('The performance test benchmark is invalid.') + } + await fs.promises.rm(sessionDir, { recursive: true }) +} + +/** Rename a completed benchmark session folder. */ +export async function renamePerformanceTestBenchmark( + performanceTestsDir: string, + sessionId: string, + newSessionId: string +): Promise { + const sessionDir = resolvePerformanceTestSessionDir(performanceTestsDir, sessionId) + const renamedSessionDir = resolvePerformanceTestSessionDir(performanceTestsDir, newSessionId) + if (!(await readPerformanceTestBenchmark(performanceTestsDir, sessionId))) { + throw new Error('The performance test benchmark is invalid.') + } + if (sessionDir === renamedSessionDir) return + try { + await fs.promises.stat(renamedSessionDir) + throw new Error('A benchmark session with that name already exists.') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + await fs.promises.rename(sessionDir, renamedSessionDir) +} + +/** Calculate duration statistics for measured jobs with valid start and end timestamps. */ +export function calculatePerformanceTestStatistics( + response: PerformanceTestJobsResponse, + measuredPromptIds: string[] +): PerformanceTestStatistics | null { + const measuredIds = new Set(measuredPromptIds) + const durations = response.jobs.flatMap((job) => { + if (!measuredIds.has(job.id) || job.status !== 'completed') return [] + const start = job.execution_start_time + const end = job.execution_end_time + if ( + typeof start !== 'number' || + !Number.isFinite(start) || + typeof end !== 'number' || + !Number.isFinite(end) || + end < start + ) { + return [] + } + return [{ jobId: job.id, durationSeconds: (end - start) / 1000 }] + }) + if (durations.length === 0) return null + + const sorted = [...durations].sort((a, b) => a.durationSeconds - b.durationSeconds) + const middle = Math.floor(sorted.length / 2) + const medianDurationSeconds = + sorted.length % 2 === 0 + ? (sorted[middle - 1]!.durationSeconds + sorted[middle]!.durationSeconds) / 2 + : sorted[middle]!.durationSeconds + return { + fastest: sorted[0]!, + slowest: sorted[sorted.length - 1]!, + averageDurationSeconds: + durations.reduce((sum, result) => sum + result.durationSeconds, 0) / durations.length, + medianDurationSeconds, + measuredJobCount: durations.length + } +} + +function isApiWorkflow(value: unknown): value is object { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const nodes = Object.values(value) + return ( + nodes.length > 0 && + nodes.every( + (node) => + node !== null && + typeof node === 'object' && + typeof (node as { class_type?: unknown }).class_type === 'string' && + (node as { inputs?: unknown }).inputs !== null && + typeof (node as { inputs?: unknown }).inputs === 'object' && + !Array.isArray((node as { inputs?: unknown }).inputs) + ) + ) +} + +/** Return a workflow copy with every numeric seed input advanced by one. */ +export function incrementWorkflowSeeds(workflow: object): object { + const nextWorkflow = structuredClone(workflow) as Record + for (const node of Object.values(nextWorkflow)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) continue + const inputs = (node as { inputs?: unknown }).inputs + if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) continue + const mutableInputs = inputs as Record + for (const [name, value] of Object.entries(inputs)) { + if ( + name.toLowerCase().includes('seed') && + typeof value === 'number' && + Number.isFinite(value) + ) { + mutableInputs[name] = value + 1 + } + } + } + return nextWorkflow +} + +function formatPerformanceTestSessionId(date: Date): string { + return [ + date.getFullYear(), + date.getMonth() + 1, + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds() + ] + .map((part) => String(part).padStart(2, '0')) + .join('') +} + +function resolveManagedWorkflowPath( + filePath: string, + userDataPath: string +): { filePath: string; sessionDir: string } { + const performanceTestsDir = path.resolve(userDataPath, PERFORMANCE_TESTS_DIR) + const resolvedPath = path.resolve(filePath) + const relativePath = path.relative(performanceTestsDir, resolvedPath) + const parts = relativePath.split(path.sep) + if ( + parts.length !== 2 || + !/^\d{14}$/.test(parts[0]!) || + path.isAbsolute(relativePath) || + parts.includes('..') + ) { + throw new Error('The workflow is outside a managed performance test session directory.') + } + return { filePath: resolvedPath, sessionDir: path.dirname(resolvedPath) } +} + +async function readPerformanceTestWorkflow( + filePath: string, + userDataPath: string +): Promise { + const managedPath = resolveManagedWorkflowPath(filePath, userDataPath).filePath + const contents = await fs.promises.readFile(managedPath, 'utf8') + let parsed: unknown + try { + parsed = JSON.parse(contents) + } catch { + throw new Error('The performance test workflow is not valid JSON.') + } + if (!isApiWorkflow(parsed)) { + throw new Error('The performance test workflow is not a ComfyUI API-format workflow.') + } + return parsed +} + +/** Read and validate the persisted data used by the results UI and image export. */ +export async function readPerformanceTestResultsSummary( + filePath: string, + userDataPath: string +): Promise { + const managedPath = resolveManagedWorkflowPath(filePath, userDataPath).filePath + if (path.basename(managedPath).toLowerCase() !== 'results.json') { + throw new Error('Select a performance test results.json file.') + } + + let parsed: unknown + try { + parsed = JSON.parse(await fs.promises.readFile(managedPath, 'utf8')) as unknown + } catch { + throw new Error('The performance test results are not valid JSON.') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('The performance test results are invalid.') + } + + const summary = parsed as Partial + const systemInfo = summary.systemInfo as Partial | undefined + const hardware = summary.hardware + if ( + typeof summary.createdAt !== 'string' || + !Number.isFinite(Date.parse(summary.createdAt)) || + !parsePerformanceTestBenchmark(summary, '') || + !Number.isInteger(summary.failedRunCount) || + summary.failedRunCount! < 0 || + !systemInfo || + typeof systemInfo.cpu_model !== 'string' || + typeof systemInfo.cpu_cores !== 'number' || + typeof systemInfo.arch !== 'string' || + typeof systemInfo.platform !== 'string' || + typeof systemInfo.os_version !== 'string' || + (hardware !== null && + (!hardware || typeof hardware !== 'object' || !Array.isArray(hardware.devices))) + ) { + throw new Error('The performance test results are invalid.') + } + return summary as PerformanceTestResultsSummary +} + +/** Validate and persist a user-selected API workflow outside any installation. */ +export async function storePerformanceTestWorkflow( + sourcePath: string, + userDataPath: string +): Promise { + if (path.extname(sourcePath).toLowerCase() !== '.json') { + throw new Error('Select a .json workflow file.') + } + const sourceFileName = path.basename(sourcePath) + if (['jobs.json', 'results.json'].includes(sourceFileName.toLowerCase())) { + throw new Error( + `The workflow filename ${sourceFileName} is reserved for performance test output.` + ) + } + + const contents = await fs.promises.readFile(sourcePath) + let parsed: unknown + try { + parsed = JSON.parse(contents.toString('utf8')) + } catch { + throw new Error('The selected file is not valid JSON.') + } + if (!isApiWorkflow(parsed)) { + throw new Error('The selected file is not a ComfyUI API-format workflow.') + } + + const performanceTestsDir = path.join(userDataPath, PERFORMANCE_TESTS_DIR) + await fs.promises.mkdir(performanceTestsDir, { recursive: true }) + + for (let offsetSeconds = 0; ; offsetSeconds++) { + const sessionId = formatPerformanceTestSessionId(new Date(Date.now() + offsetSeconds * 1000)) + const sessionDir = path.join(performanceTestsDir, sessionId) + try { + await fs.promises.mkdir(sessionDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue + throw error + } + + const destinationPath = path.join(sessionDir, sourceFileName) + try { + await fs.promises.writeFile(destinationPath, contents) + return destinationPath + } catch (error) { + await fs.promises.rm(sessionDir, { recursive: true, force: true }) + throw error + } + } +} + +/** Delete a workflow copy managed by the performance test page. */ +export async function deletePerformanceTestWorkflow( + filePath: string, + userDataPath: string +): Promise { + const managedPath = resolveManagedWorkflowPath(filePath, userDataPath) + for (const outputName of ['jobs.json', 'results.json', 'logs.txt']) { + try { + await fs.promises.access(path.join(managedPath.sessionDir, outputName)) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + await fs.promises.unlink(managedPath.filePath) + await fs.promises.rmdir(managedPath.sessionDir).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOTEMPTY') throw error + }) +} + +/** Queue warm-up requests followed by each measured run. */ +export async function submitPerformanceTestWorkflow( + filePath: string, + userDataPath: string, + sessionUrl: string, + measuredRuns: number, + warmupRuns: number, + fetchImpl: typeof fetch = fetch +): Promise { + if (!Number.isInteger(measuredRuns) || measuredRuns < 1 || measuredRuns > 100) { + throw new Error('Measured runs must be an integer between 1 and 100.') + } + if (!Number.isInteger(warmupRuns) || warmupRuns < 1 || warmupRuns > 5) { + throw new Error('Warm-up runs must be an integer between 1 and 5.') + } + + let workflow = await readPerformanceTestWorkflow(filePath, userDataPath) + const endpoint = new URL('/prompt', sessionUrl) + const promptIds: string[] = [] + const totalRuns = measuredRuns + warmupRuns + + for (let run = 1; run <= totalRuns; run++) { + workflow = incrementWorkflowSeeds(workflow) + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: workflow }) + }) + if (!response.ok) { + const detail = (await response.text()).trim() + throw new Error( + `Performance Test request ${run} failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}` + ) + } + const result = (await response.json()) as { prompt_id?: unknown; error?: unknown } + if (result.error) { + throw new Error(`Performance Test request ${run} failed: ${String(result.error)}`) + } + if (typeof result.prompt_id !== 'string') { + throw new Error(`Performance Test request ${run} did not return a prompt ID.`) + } + promptIds.push(result.prompt_id) + } + + return promptIds +} + +/** Poll the jobs collection until every submitted prompt reaches a terminal state. */ +export async function waitForPerformanceTestJobs( + sessionUrl: string, + promptIds: string[], + fetchImpl: typeof fetch = fetch, + pollIntervalMs = PERFORMANCE_TEST_POLL_INTERVAL_MS, + onProgress?: (completedRuns: number, totalRuns: number) => void +): Promise { + const endpoint = new URL('/api/jobs', sessionUrl) + endpoint.searchParams.set('limit', String(promptIds.length)) + const expectedPromptIds = new Set(promptIds) + + for (;;) { + const response = await fetchImpl(endpoint) + if (!response.ok) { + const detail = (await response.text()).trim() + throw new Error( + `Could not check performance test jobs: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}` + ) + } + + const result = (await response.json()) as Partial + if (!Array.isArray(result.jobs)) { + throw new Error('The ComfyUI jobs response did not contain a jobs array.') + } + + const jobs = result.jobs.filter( + (job): job is PerformanceTestJob => + job !== null && + typeof job === 'object' && + typeof job.id === 'string' && + typeof job.status === 'string' && + expectedPromptIds.has(job.id) + ) + const statuses = new Map(jobs.map((job) => [job.id, job.status])) + const completedRuns = [...expectedPromptIds].filter((id) => { + const status = statuses.get(id) + return status !== undefined && TERMINAL_JOB_STATUSES.has(status) + }).length + onProgress?.(completedRuns, expectedPromptIds.size) + const allTerminal = [...expectedPromptIds].every((id) => { + const status = statuses.get(id) + return status !== undefined && TERMINAL_JOB_STATUSES.has(status) + }) + if (allTerminal) return { ...result, jobs } as PerformanceTestJobsResponse + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } +} + +/** Persist the final jobs API response and return its absolute path. */ +export async function savePerformanceTestJobsResponse( + response: PerformanceTestJobsResponse, + workflowFilePath: string, + userDataPath: string +): Promise { + const { sessionDir } = resolveManagedWorkflowPath(workflowFilePath, userDataPath) + const resultPath = path.join(sessionDir, 'jobs.json') + await fs.promises.writeFile(resultPath, `${JSON.stringify(response, null, 2)}\n`, 'utf8') + return resultPath +} + +/** Persist the instance output displayed by the performance test page. */ +export async function savePerformanceTestLogs( + logs: string, + workflowFilePath: string, + userDataPath: string +): Promise { + const { sessionDir } = resolveManagedWorkflowPath(workflowFilePath, userDataPath) + const logsPath = path.join(sessionDir, 'logs.txt') + await fs.promises.writeFile(logsPath, logs, 'utf8') + return logsPath +} + +/** Persist the displayed performance test summary beside the workflow and raw jobs response. */ +export async function savePerformanceTestResultsSummary( + statistics: PerformanceTestStatistics | null, + instance: PerformanceTestResultsSummary['instance'], + workspace: PerformanceTestResultsSummary['workspace'], + hardware: AcceleratorSnapshot | null, + systemInfo: SystemInfo, + workflowFilePath: string, + userDataPath: string, + successfulRunCount: number, + failedRunCount: number +): Promise { + const { sessionDir } = resolveManagedWorkflowPath(workflowFilePath, userDataPath) + const summary: PerformanceTestResultsSummary = { + createdAt: new Date().toISOString(), + instance, + workspace, + workflowName: path.basename(workflowFilePath), + fastestJobDurationSeconds: statistics?.fastest.durationSeconds ?? null, + slowestJobDurationSeconds: statistics?.slowest.durationSeconds ?? null, + averageJobDurationSeconds: statistics?.averageDurationSeconds ?? null, + medianJobDurationSeconds: statistics?.medianDurationSeconds ?? null, + measuredJobCount: successfulRunCount, + failedRunCount, + hardware, + systemInfo + } + const summaryPath = path.join(sessionDir, 'results.json') + await fs.promises.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + return summaryPath +} diff --git a/src/main/popups/titlePopup.test.ts b/src/main/popups/titlePopup.test.ts index 9dfa995c5..8e23ebf1e 100644 --- a/src/main/popups/titlePopup.test.ts +++ b/src/main/popups/titlePopup.test.ts @@ -167,10 +167,14 @@ describe('buildTitlePopupMenuItems', () => { expect(ids).toContain('load-snapshot') }) - it('chooser host includes New Window, Settings, Send Feedback, Close Window, and Quit Desktop', () => { + it('chooser host includes Performance Test, Benchmarks, Settings, and window actions', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const ids = items.map((i) => i.id ?? null) expect(ids).toContain('new-window') + expect(ids).toContain('performance-test') + expect(items.find((item) => item.id === 'performance-test')?.label).toBe('Performance Tests') + expect(ids).toContain('benchmarks') + expect(items.find((item) => item.id === 'benchmarks')?.label).toBe('Benchmarks') expect(ids).toContain('settings') expect(ids).toContain('feedback') expect(ids).toContain('exit-window') @@ -188,6 +192,8 @@ describe('buildTitlePopupMenuItems', () => { 'track', 'load-snapshot', 'sign-in', + 'performance-test', + 'benchmarks', 'settings', 'feedback', 'exit-window', @@ -207,6 +213,8 @@ describe('buildTitlePopupMenuItems', () => { 'track', 'load-snapshot', 'sign-in', + 'performance-test', + 'benchmarks', 'settings', 'feedback', 'exit-window', @@ -227,6 +235,8 @@ describe('buildTitlePopupMenuItems', () => { 'new-install', 'track', 'load-snapshot', + 'performance-test', + 'benchmarks', 'settings', 'feedback', 'exit-window', @@ -243,6 +253,8 @@ describe('buildTitlePopupMenuItems', () => { 'track', 'load-snapshot', 'sign-in', + 'performance-test', + 'benchmarks', 'settings', 'feedback', 'reset-zoom', @@ -296,19 +308,28 @@ describe('buildTitlePopupMenuItems', () => { expect(ids[ids.length - 1]).toBe('close-all-windows') }) - it('separates Log in from Desktop Settings while signed out', () => { + it('separates Log in from the Performance Test and Benchmarks group', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const signInIdx = items.findIndex((i) => i.id === 'sign-in') expect(items[signInIdx + 1]?.kind).toBe('separator') - expect(items[signInIdx + 2]?.id).toBe('settings') + expect(items[signInIdx + 2]?.id).toBe('performance-test') + expect(items[signInIdx + 3]?.id).toBe('benchmarks') }) - it('does not leave a doubled separator above Desktop Settings once signed in', () => { + it('does not leave a doubled separator above Performance Test once signed in', () => { devPlatformMocks.isSignedInToCloud.mockReturnValue(true) + const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) + const performanceTestsIdx = items.findIndex((i) => i.id === 'performance-test') + expect(items[performanceTestsIdx - 1]?.kind).toBe('separator') + expect(items[performanceTestsIdx - 2]?.kind).not.toBe('separator') + }) + + it('groups Performance Test and Benchmarks above a separator and Desktop Settings', () => { const items = buildTitlePopupMenuItems(makeEntry({ installationId: null })) const settingsIdx = items.findIndex((i) => i.id === 'settings') expect(items[settingsIdx - 1]?.kind).toBe('separator') - expect(items[settingsIdx - 2]?.kind).not.toBe('separator') + expect(items[settingsIdx - 2]?.id).toBe('benchmarks') + expect(items[settingsIdx - 3]?.id).toBe('performance-test') }) it('separators bracket the install-creation block on both hosts', () => { @@ -346,6 +367,43 @@ describe('activateTitlePopupMenuItem', () => { } as unknown as Parameters[0] } + it.each([null, 'inst-1'] as const)( + 'opens Performance Test in the current host when installationId is %s', + (installationId) => { + const host = makeEntry({ installationId }) + comfyWindows.set(host.windowKey, host) + const bindings = { + openChooserHostWindow: vi.fn(), + setActivePanel: vi.fn() + } as unknown as TitlePopupHostBindings + + activateTitlePopupMenuItem(makePopupEntry(host.windowKey), 'performance-test', bindings) + + expect(bindings.setActivePanel).toHaveBeenCalledExactlyOnceWith( + host.windowKey, + 'performance-test' + ) + expect(bindings.openChooserHostWindow).not.toHaveBeenCalled() + } + ) + + it.each([null, 'inst-1'] as const)( + 'opens Benchmarks in the current host when installationId is %s', + (installationId) => { + const host = makeEntry({ installationId }) + comfyWindows.set(host.windowKey, host) + const bindings = { + openChooserHostWindow: vi.fn(), + setActivePanel: vi.fn() + } as unknown as TitlePopupHostBindings + + activateTitlePopupMenuItem(makePopupEntry(host.windowKey), 'benchmarks', bindings) + + expect(bindings.setActivePanel).toHaveBeenCalledExactlyOnceWith(host.windowKey, 'benchmarks') + expect(bindings.openChooserHostWindow).not.toHaveBeenCalled() + } + ) + it('routes Reset Zoom through resetComfyZoom with the host installation id', () => { const host = makeEntry({ installationId: 'inst-1', zoomLevel: 3 }) comfyWindows.set(host.windowKey, host) @@ -653,6 +711,50 @@ describe('buildInstancePickerSnapshot', () => { }) }) +describe('title popup renderer readiness', () => { + type IpcListener = (event: Electron.IpcMainEvent) => void + let ready: IpcListener + + beforeAll(async () => { + const { ipcMain } = await import('electron') + registerTitlePopupIpc({} as TitlePopupHostBindings) + const call = vi + .mocked(ipcMain.on) + .mock.calls.find(([channel]) => channel === 'comfy-titlepopup:ready') + if (!call) throw new Error('IPC listener not registered: comfy-titlepopup:ready') + ready = call[1] as IpcListener + }) + + afterAll(() => { + _test_deleteTitlePopupEntry(404) + }) + + it('replays the last config when the cached popup renderer reloads', () => { + const config = { + kind: 'menu' as const, + items: [{ id: 'settings', label: 'Desktop Settings' }], + theme: { bg: '#111111', text: '#eeeeee' } + } + const send = vi.fn() + const entry = { + view: { + rendererReady: false, + popup: { webContents: { isDestroyed: () => false, send } } + }, + pendingConfig: null, + lastConfigJson: JSON.stringify(config), + lastSyncedConfigJson: JSON.stringify(config) + } as unknown as TitlePopupEntry + _test_setTitlePopupEntry(404, entry) + + ready({ sender: { id: 404 } } as Electron.IpcMainEvent) + + expect(entry.view.rendererReady).toBe(true) + expect(entry.lastSyncedConfigJson).toBeNull() + expect(send).toHaveBeenCalledExactlyOnceWith('comfy-titlepopup:set-config', config) + }) +}) + describe('global settings IPC handlers', () => { type IpcHandler = ( event: Electron.IpcMainInvokeEvent, diff --git a/src/main/popups/titlePopup.ts b/src/main/popups/titlePopup.ts index ae23f5996..c46484eb1 100644 --- a/src/main/popups/titlePopup.ts +++ b/src/main/popups/titlePopup.ts @@ -753,6 +753,9 @@ export function buildTitlePopupMenuItems(entry: ComfyWindowEntry): TitlePopupMen // Load Snapshot // ── separator ── // (Log in — while signed out, followed by its own separator) + // Performance Test + // Benchmarks + // ── separator ── // Desktop Settings // Send Beta Feedback // (Reset Zoom — on install-backed hosts when zoom level != 0) @@ -801,6 +804,13 @@ export function buildTitlePopupMenuItems(entry: ComfyWindowEntry): TitlePopupMen ) } items.push( + { + id: 'performance-test', + label: 'Performance Tests', + labelKey: 'fileMenu.performanceTest' + }, + { id: 'benchmarks', label: 'Benchmarks', labelKey: 'fileMenu.benchmarks' }, + { kind: 'separator' }, { id: 'settings', label: 'Desktop Settings', @@ -1961,6 +1971,8 @@ export function activateTitlePopupMenuItem( if (id === 'new-window') { bindings.openChooserHostWindow() releaseFocusToParent = false + } else if (id === 'performance-test' || id === 'benchmarks') { + if (parentEntry) bindings.setActivePanel(entry.parentEntryId, id) } else if (id === 'return-to-dashboard') { // Flip the install-backed host in place to chooser-host mode. // The same BrowserWindow stays alive; the file-menu popup is @@ -2271,8 +2283,16 @@ export function registerTitlePopupIpc(bindings: TitlePopupHostBindings): void { const entry = titlePopupsByWebContents.get(event.sender.id) if (!entry) return entry.view.rendererReady = true - if (entry.pendingConfig && !entry.view.popup.webContents.isDestroyed()) { - const flushed = entry.pendingConfig + // A ready signal means this is a freshly-mounted renderer. In development, + // Vite can reload the cached popup WebContentsView while main still holds + // the prior sync marker; replay that config so the default empty menu state + // cannot be mistaken for an already-synchronised renderer on the next open. + entry.lastSyncedConfigJson = null + const queuedConfig = + entry.pendingConfig ?? + (entry.lastConfigJson ? (JSON.parse(entry.lastConfigJson) as TitlePopupConfig) : null) + if (queuedConfig && !entry.view.popup.webContents.isDestroyed()) { + const flushed = queuedConfig entry.lastConfigJson = JSON.stringify(flushed) entry.view.popup.webContents.send('comfy-titlepopup:set-config', flushed) entry.pendingConfig = null diff --git a/src/preload/api.ts b/src/preload/api.ts index e68304902..31f18cb98 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -28,6 +28,30 @@ export function buildElectronApi(): ElectronApi { // File/URL browseFolder: (defaultPath?) => ipcRenderer.invoke('browse-folder', defaultPath), + importPerformanceTestWorkflow: (filePath?) => + ipcRenderer.invoke('import-performance-test-workflow', filePath), + deletePerformanceTestWorkflow: (filePath) => + ipcRenderer.invoke('delete-performance-test-workflow', filePath), + savePerformanceTestLogs: (filePath, logs) => + ipcRenderer.invoke('save-performance-test-logs', filePath, logs), + listPerformanceTestBenchmarks: (folderPath?) => + ipcRenderer.invoke('list-performance-test-benchmarks', folderPath), + deletePerformanceTestBenchmark: (folderPath, sessionId) => + ipcRenderer.invoke('delete-performance-test-benchmark', folderPath, sessionId), + renamePerformanceTestBenchmark: (folderPath, sessionId, newSessionId) => + ipcRenderer.invoke('rename-performance-test-benchmark', folderPath, sessionId, newSessionId), + readPerformanceTestResultsSummary: (filePath) => + ipcRenderer.invoke('read-performance-test-results-summary', filePath), + runPerformanceTestWorkflow: (sessionId, filePath, measuredRuns, warmupRuns) => + ipcRenderer.invoke( + 'run-performance-test-workflow', + sessionId, + filePath, + measuredRuns, + warmupRuns + ), + exportResultsImage: (png, imageType, defaultPath?) => + ipcRenderer.invoke('export-results-image', png, imageType, defaultPath), openPath: (targetPath) => ipcRenderer.invoke('open-path', targetPath), openExternal: (url) => ipcRenderer.invoke('open-external', url), getDiskSpace: (targetPath) => ipcRenderer.invoke('get-disk-space', targetPath), @@ -275,6 +299,12 @@ export function buildElectronApi(): ElectronApi { ipcRenderer.on('comfy-output', handler) return () => ipcRenderer.removeListener('comfy-output', handler) }, + onPerformanceTestProgress: (callback) => { + const handler = (_event: IpcRendererEvent, data: unknown) => + callback(data as Parameters[0]) + ipcRenderer.on('performance-test-progress', handler) + return () => ipcRenderer.removeListener('performance-test-progress', handler) + }, onComfyExited: (callback) => { const handler = (_event: IpcRendererEvent, data: unknown) => callback(data as Parameters[0]) diff --git a/src/preload/comfyTitleBarPreload.ts b/src/preload/comfyTitleBarPreload.ts index fe387790b..972cbdba5 100644 --- a/src/preload/comfyTitleBarPreload.ts +++ b/src/preload/comfyTitleBarPreload.ts @@ -3,7 +3,14 @@ import type { IpcRendererEvent } from 'electron' import { buildElectronApi } from './api' import { normaliseFirstUseMode, type FirstUseMode } from '../shared/firstUseMode' -export type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' +export type ComfyPanelKey = + | 'comfy' + | 'performance-test' + | 'benchmarks' + | 'new-install' + | 'track' + | 'load-snapshot' + | 'quick-install' /** Anchor coordinates for a native title-bar menu — title-bar-local * pixels (x = button left, y = button bottom). The titleBarView sits diff --git a/src/renderer/src/comfyTitleBar/TitleBarApp.vue b/src/renderer/src/comfyTitleBar/TitleBarApp.vue index 4b2d7a54c..90c172c2c 100644 --- a/src/renderer/src/comfyTitleBar/TitleBarApp.vue +++ b/src/renderer/src/comfyTitleBar/TitleBarApp.vue @@ -29,7 +29,14 @@ const { syncLocale } = useAppLocale(windowApiLocaleSource()) // file isn't visible to tsconfig.web (only its .d.ts would be). Kept in // sync with the literal union in src/preload/comfyTitleBarPreload.ts and // the ComfyPanelKey export in src/main/index.ts. -type ComfyPanelKey = 'comfy' | 'new-install' | 'track' | 'load-snapshot' | 'quick-install' +type ComfyPanelKey = + | 'comfy' + | 'performance-test' + | 'benchmarks' + | 'new-install' + | 'track' + | 'load-snapshot' + | 'quick-install' /** Position passed to main so the native menu pops below the anchor button. * Coordinates are in title-bar-local pixels — main translates to window diff --git a/src/renderer/src/components/BrandedPageHeader.vue b/src/renderer/src/components/BrandedPageHeader.vue new file mode 100644 index 000000000..4291f86b5 --- /dev/null +++ b/src/renderer/src/components/BrandedPageHeader.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/renderer/src/components/CollapsibleSectionToggle.vue b/src/renderer/src/components/CollapsibleSectionToggle.vue new file mode 100644 index 000000000..0490d7f1d --- /dev/null +++ b/src/renderer/src/components/CollapsibleSectionToggle.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/renderer/src/composables/useInstallContextMenu.ts b/src/renderer/src/composables/useInstallContextMenu.ts index 01ee11f7c..334c9631e 100644 --- a/src/renderer/src/composables/useInstallContextMenu.ts +++ b/src/renderer/src/composables/useInstallContextMenu.ts @@ -383,6 +383,36 @@ export function useInstallContextMenu( ctxMenu.value.open = false } + /** Surface either an operation failure or a crashed instance's details. */ + function viewError(inst: Installation): void { + const err = sessionStore.errorInstances.get(inst.id) + if (!err) return + let message = err.message + if (!message) { + if (err.signal && err.exitCode != null) { + message = t('comfyLifecycle.crashedDescWithCodeAndSignal', { + code: err.exitCode, + signal: err.signal + }) + } else if (err.signal) { + message = t('comfyLifecycle.crashedDescWithSignal', { signal: err.signal }) + } else if (err.exitCode != null) { + message = t('comfyLifecycle.crashedDescWithCode', { code: err.exitCode }) + } else { + message = t('comfyLifecycle.crashedDesc') + } + } + if (err.lastStderr) message = `${message}\n\n${err.lastStderr}` + void modal.alert({ title: t('chooser.errorTitle'), message }) + } + + /** Surface the full explanation for a backend-provided danger status. */ + function viewDanger(inst: Installation): void { + const tag = inst.statusTag + if (!tag || tag.style !== 'danger') return + void modal.alert({ title: tag.label, message: tag.detail || tag.label }) + } + return { ctxMenu, ctxMenuItems, @@ -391,6 +421,8 @@ export function useInstallContextMenu( handleCtxMenuSelect, closeMenu, triggerAction, + viewError, + viewDanger, isStoppedActionGated, isPromotingToWorkspace } diff --git a/src/renderer/src/composables/useWorkspaceInstallScope.ts b/src/renderer/src/composables/useWorkspaceInstallScope.ts new file mode 100644 index 000000000..603dd7eef --- /dev/null +++ b/src/renderer/src/composables/useWorkspaceInstallScope.ts @@ -0,0 +1,40 @@ +import { computed, watch, type Ref } from 'vue' +import { useAuthStore } from '../stores/authStore' +import { useDashboardScopeStore } from '../stores/dashboardScopeStore' +import type { Installation } from '../types/ipc' +import { PERSONAL_WORKSPACE_ID } from '../../../shared/workspaces' + +/** Keeps install lists scoped to the workspace selected in the shared dashboard control. */ +export function useWorkspaceInstallScope(installations: Ref) { + const authStore = useAuthStore() + const dashboardScope = useDashboardScopeStore() + const selectedWorkspaceId = computed({ + get: () => dashboardScope.selectedWorkspaceId, + set: dashboardScope.selectWorkspace + }) + void dashboardScope.initialize() + + watch( + [() => authStore.isSignedIn, () => authStore.status.workspaceId], + ([signedIn, workspaceId]) => { + if (signedIn && workspaceId) void authStore.fetchBuilds() + }, + { immediate: true } + ) + + function installationIsInSelectedScope(installation: Installation): boolean { + return selectedWorkspaceId.value === PERSONAL_WORKSPACE_ID + ? installation.workspaceId === undefined || installation.workspaceId === PERSONAL_WORKSPACE_ID + : installation.workspaceId === selectedWorkspaceId.value + } + + const scopedInstallations = computed(() => + installations.value.filter(installationIsInSelectedScope) + ) + + return { + selectedWorkspaceId, + installationIsInSelectedScope, + scopedInstallations + } +} diff --git a/src/renderer/src/lib/benchmarkComparisonSvg.test.ts b/src/renderer/src/lib/benchmarkComparisonSvg.test.ts new file mode 100644 index 000000000..fc2be4f54 --- /dev/null +++ b/src/renderer/src/lib/benchmarkComparisonSvg.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + createBenchmarkComparisonSvg, + type BenchmarkComparisonImageData +} from './benchmarkComparisonSvg' + +function comparisonData(workflowName: string): BenchmarkComparisonImageData { + return { + title: 'Benchmark comparison', + metricTitle: 'Metric', + durationRangeTitle: 'Duration range', + exportDateTime: 'Sep 20, 2026', + runs: [ + { + color: '#55e0d1', + properties: [ + { label: 'Workflow', value: workflowName }, + { label: 'Session', value: 'Session A' } + ], + metrics: [{ label: 'Average', value: '2 s', highlighted: false }], + fastestDurationSeconds: 1, + averageDurationSeconds: 2, + slowestDurationSeconds: 3 + } + ] + } +} + +function chartTrackStart(svg: string): number { + const match = svg.match(/]+class="chart-track"/) + if (!match) throw new Error('Chart track not found.') + return Number(match[1]) +} + +describe('createBenchmarkComparisonSvg', () => { + it('starts aligned chart tracks after the longest run information', () => { + const shortLabelStart = chartTrackStart(createBenchmarkComparisonSvg(comparisonData('a.json'))) + const longLabelStart = chartTrackStart( + createBenchmarkComparisonSvg(comparisonData('considerably-longer-workflow-name.json')) + ) + + expect(longLabelStart).toBeGreaterThan(shortLabelStart) + }) +}) diff --git a/src/renderer/src/lib/benchmarkComparisonSvg.ts b/src/renderer/src/lib/benchmarkComparisonSvg.ts new file mode 100644 index 000000000..1d7d7d08f --- /dev/null +++ b/src/renderer/src/lib/benchmarkComparisonSvg.ts @@ -0,0 +1,228 @@ +import comfyWordmarkSource from '../components/icons/ComfyWordmark.vue?raw' + +export interface BenchmarkComparisonImageProperty { + label: string + value: string +} + +export interface BenchmarkComparisonImageMetric extends BenchmarkComparisonImageProperty { + highlighted: boolean +} + +export interface BenchmarkComparisonImageRun { + color: string + properties: BenchmarkComparisonImageProperty[] + metrics: BenchmarkComparisonImageMetric[] + fastestDurationSeconds: number | null + averageDurationSeconds: number | null + slowestDurationSeconds: number | null +} + +export interface BenchmarkComparisonImageData { + title: string + metricTitle: string + durationRangeTitle: string + exportDateTime: string + runs: BenchmarkComparisonImageRun[] +} + +const COMFY_WORDMARK_PATH = comfyWordmarkSource.match(/\sd="([^"]+)"/)?.[1] + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function text(x: number, y: number, value: string, className: string, anchor = 'start'): string { + return `${escapeXml(value)}` +} + +function wrapText(value: string, maximumLength = 42): string[] { + const lines: string[] = [] + for (const word of value.split(/\s+/)) { + const previous = lines.at(-1) + if (!previous) lines.push(word) + else if (`${previous} ${word}`.length <= maximumLength) + lines[lines.length - 1] = `${previous} ${word}` + else lines.push(word) + } + return lines.flatMap((line) => { + if (line.length <= maximumLength) return line + return Array.from({ length: Math.ceil(line.length / maximumLength) }, (_, index) => + line.slice(index * maximumLength, (index + 1) * maximumLength) + ) + }) +} + +function propertyTextLines(properties: BenchmarkComparisonImageProperty[]) { + return properties.flatMap((property, propertyIndex) => { + const value = propertyIndex === 0 ? property.value : `${property.label}: ${property.value}` + return wrapText(value).map((line) => ({ line, propertyIndex })) + }) +} + +function estimatedPropertyLineWidth(line: string, propertyIndex: number): number { + return line.length * (propertyIndex === 0 ? 7.2 : 6.6) +} + +function propertyLines( + properties: BenchmarkComparisonImageProperty[], + x: number, + y: number +): string { + return propertyTextLines(properties) + .map(({ line, propertyIndex }, index) => { + return text(x, y + index * 19, line, propertyIndex === 0 ? 'run-title' : 'run-property') + }) + .join('') +} + +function formatDuration(value: number): string { + return `${value.toFixed(2).replace(/\.?0+$/, '')} s` +} + +/** Create a self-contained SVG containing the selected benchmark comparison. */ +export function createBenchmarkComparisonSvg(data: BenchmarkComparisonImageData): string { + const margin = 56 + const metricColumnWidth = 170 + const runCount = Math.max(1, data.runs.length) + const width = Math.max(1200, margin * 2 + metricColumnWidth + runCount * 270) + const runColumnWidth = (width - margin * 2 - metricColumnWidth) / runCount + const runPropertyLines = data.runs.map((run) => propertyTextLines(run.properties)) + const propertyLineCount = Math.max(1, ...runPropertyLines.map((lines) => lines.length)) + const chartLabelWidth = + 20 + + Math.max( + 0, + ...runPropertyLines.flatMap((lines) => + lines.map(({ line, propertyIndex }) => estimatedPropertyLineWidth(line, propertyIndex)) + ) + ) + const headerHeight = Math.max(88, 34 + propertyLineCount * 19) + const metricCount = Math.max(1, ...data.runs.map((run) => run.metrics.length)) + const metricRowHeight = 42 + const tableX = margin + const tableY = 90 + const tableWidth = width - margin * 2 + const tableHeight = headerHeight + metricCount * metricRowHeight + const chartY = tableY + tableHeight + 70 + const chartLabelGap = 10 + const chartEndpointLabelSpace = 56 + const chartStart = margin + chartLabelWidth + chartLabelGap + chartEndpointLabelSpace + const chartEnd = width - margin - chartEndpointLabelSpace + const chartWidth = chartEnd - chartStart + const chartRowHeight = Math.max(96, 52 + propertyLineCount * 19) + const height = chartY + 44 + data.runs.length * chartRowHeight + 80 + const maximumDuration = Math.max( + 1, + ...data.runs.flatMap((run) => + run.slowestDurationSeconds === null ? [] : [run.slowestDurationSeconds] + ) + ) + const durationX = (value: number) => chartStart + (value / maximumDuration) * chartWidth + + const headers = data.runs + .map((run, index) => { + const x = tableX + metricColumnWidth + index * runColumnWidth + return [ + ``, + ``, + propertyLines(run.properties, x + 32, tableY + 29) + ].join('') + }) + .join('') + + const metricRows = Array.from({ length: metricCount }, (_, rowIndex) => { + const y = tableY + headerHeight + rowIndex * metricRowHeight + const label = data.runs.find((run) => run.metrics[rowIndex])?.metrics[rowIndex]?.label ?? '' + const values = data.runs + .map((run, runIndex) => { + const x = tableX + metricColumnWidth + runIndex * runColumnWidth + const metric = run.metrics[rowIndex] + const cellClass = metric?.highlighted ? 'table-cell best-cell' : 'table-cell' + const valueClass = metric?.highlighted ? 'metric-value best-value' : 'metric-value' + return [ + ``, + text(x + runColumnWidth / 2, y + 27, metric?.value ?? '-', valueClass, 'middle') + ].join('') + }) + .join('') + return [ + ``, + text(tableX + 14, y + 27, label, 'metric-label'), + values + ].join('') + }).join('') + + const chartRows = data.runs + .map((run, index) => { + const y = chartY + 44 + index * chartRowHeight + const trackY = y + Math.max(42, (runPropertyLines[index]!.length * 19) / 2) + const fastest = run.fastestDurationSeconds + const average = run.averageDurationSeconds + const slowest = run.slowestDurationSeconds + const fastestX = fastest === null ? null : durationX(fastest) + const averageX = average === null ? null : durationX(average) + const slowestX = slowest === null ? null : durationX(slowest) + const range = + fastest !== null && slowest !== null && fastestX !== null && slowestX !== null + ? ` + + + ${text(fastestX - 4, trackY + 4, formatDuration(fastest), 'chart-point-label', 'end')} + ${text(slowestX + 4, trackY + 4, formatDuration(slowest), 'chart-point-label', 'start')}` + : '' + const averageMarker = + average === null || averageX === null + ? '' + : ` + ${text(averageX, trackY + 22, formatDuration(average), 'chart-point-label', 'middle')}` + return [ + ``, + propertyLines(run.properties, margin + 20, y + 12), + ``, + range, + averageMarker + ].join('') + }) + .join('') + + return ` + + + + ${text(margin, 55, data.title, 'title')} + + ${text(tableX + 14, tableY + headerHeight - 14, data.metricTitle, 'metric-label')} + ${headers} + ${metricRows} + ${text(margin, chartY, data.durationRangeTitle, 'section-title')} + ${chartRows} + + + + ${text(width - margin, height - 28, data.exportDateTime, 'footer-date', 'end')} +` +} diff --git a/src/renderer/src/lib/openInstallManager.ts b/src/renderer/src/lib/openInstallManager.ts new file mode 100644 index 000000000..d21d5d437 --- /dev/null +++ b/src/renderer/src/lib/openInstallManager.ts @@ -0,0 +1,25 @@ +import { resolvePickerTab } from './pickerTabs' +import type { Installation } from '../types/ipc' + +export interface OpenInstallManagerOptions { + initialTab?: string + autoAction?: string | null +} + +/** Open the shared instance manager, optionally targeting and firing an action. */ +export function openInstallManager( + installation: Installation, + opts: OpenInstallManagerOptions = {} +): void { + const hasSpecialisedOpts = + opts.initialTab !== undefined || (opts.autoAction !== undefined && opts.autoAction !== null) + if (!hasSpecialisedOpts) { + window.api.openInstancePicker({ installationId: installation.id }) + return + } + window.api.openInstancePicker({ + installationId: installation.id, + initialTab: resolvePickerTab(opts.initialTab, 'status'), + autoAction: opts.autoAction ?? null + }) +} diff --git a/src/renderer/src/lib/performanceTestResultsSvg.test.ts b/src/renderer/src/lib/performanceTestResultsSvg.test.ts new file mode 100644 index 000000000..948652fff --- /dev/null +++ b/src/renderer/src/lib/performanceTestResultsSvg.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { createPerformanceTestResultsSvg } from './performanceTestResultsSvg' + +describe('createPerformanceTestResultsSvg', () => { + it('renders the result metrics and escapes system values as valid XML', () => { + const svg = createPerformanceTestResultsSvg({ + title: 'Performance Test: cat-workflow.json', + aggregateTitle: 'Run duration aggregates', + systemInformationTitle: 'System information', + testDateTime: 'Sep 11, 2026, 12:34 PM', + metrics: [ + { label: 'Measured runs', value: '5' }, + { label: 'Fastest run', value: '1.250 s', durationSeconds: 1.25 } + ], + hardware: [{ label: 'Compute device', value: 'GPU & efficient' }], + system: [{ label: 'CPU', value: 'Example CPU' }] + }) + + expect(svg).toContain('', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function text(x: number, y: number, value: string, className: string, anchor = 'start'): string { + return `${escapeXml(value)}` +} + +function informationCard( + x: number, + y: number, + width: number, + height: number, + rows: PerformanceTestImageMetric[] +): string { + const content = rows + .map((row, index) => { + const rowY = y + 35 + index * 43 + return [ + text(x + 20, rowY, row.label, 'info-label'), + text(x + width - 20, rowY, row.value, 'info-value', 'end'), + `` + ].join('') + }) + .join('') + + return `${content}` +} + +/** Create a dependency-free, self-contained SVG matching the visible results summary. */ +export function createPerformanceTestResultsSvg(data: PerformanceTestResultsImageData): string { + const width = 1200 + const margin = 56 + const metricGap = 16 + const metricWidth = + (width - margin * 2 - metricGap * (data.metrics.length - 1)) / data.metrics.length + const chartMetrics = data.metrics.filter((metric) => metric.durationSeconds != null) + const longestDuration = Math.max(...chartMetrics.map((metric) => metric.durationSeconds!), 0.001) + const systemY = chartMetrics.length > 0 ? 340 : 235 + const cardGap = 20 + const cardWidth = (width - margin * 2 - cardGap) / 2 + const cardHeight = Math.max(data.hardware.length, data.system.length) * 43 + 34 + const height = systemY + 50 + cardHeight + 56 + + const metricCards = data.metrics + .map((metric, index) => { + const x = margin + index * (metricWidth + metricGap) + const right = x + metricWidth - 16 + return [ + ``, + text(right, 122, metric.label, 'metric-label', 'end'), + text(right, 160, metric.value, 'metric-value', 'end') + ].join('') + }) + .join('') + + const chart = chartMetrics + .map((metric, index) => { + const y = 235 + index * 24 + const barWidth = (metric.durationSeconds! / longestDuration) * 800 + return [ + text(margin, y + 10, metric.label, 'chart-label'), + ``, + `` + ].join('') + }) + .join('') + + return ` + + + + ${text(margin, 55, data.title, 'title')} + ${metricCards} + ${chartMetrics.length > 0 ? text(margin, 215, data.aggregateTitle, 'section-title') : ''} + ${chart} + ${text(margin, systemY, data.systemInformationTitle, 'section-title')} + ${informationCard(margin, systemY + 24, cardWidth, cardHeight, data.hardware)} + ${informationCard(margin + cardWidth + cardGap, systemY + 24, cardWidth, cardHeight, data.system)} + + + + ${text(width - margin, height - 28, data.testDateTime, 'footer-date', 'end')} +` +} + +/** Rasterize the self-contained results SVG at its intrinsic dimensions. */ +export async function createResultsPng(svg: string): Promise { + const dimensions = svg.match(/]*\bwidth="(\d+)"[^>]*\bheight="(\d+)"/) + const width = Number(dimensions?.[1]) + const height = Number(dimensions?.[2]) + if (!width || !height) throw new Error('Invalid results image dimensions.') + + const image = new Image() + await new Promise((resolve, reject) => { + image.onload = () => resolve() + image.onerror = () => reject(new Error('Could not render the results image.')) + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` + }) + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) throw new Error('Could not render the results image.') + context.drawImage(image, 0, 0, width, height) + + const png = await new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('Could not encode the results image.'))), + 'image/png' + ) + }) + return png.arrayBuffer() +} diff --git a/src/renderer/src/panel/PanelApp.test.ts b/src/renderer/src/panel/PanelApp.test.ts index b889f641a..2ad5b6264 100644 --- a/src/renderer/src/panel/PanelApp.test.ts +++ b/src/renderer/src/panel/PanelApp.test.ts @@ -1,8 +1,17 @@ // @vitest-environment-options {"settings":{"navigation":{"disableChildFrameNavigation":true}}} // Keep the feedback iframe in the DOM without loading the external support site. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as PerformanceTestResultsSvg from '../lib/performanceTestResultsSvg' const installWizardOpen = vi.hoisted(() => vi.fn()) +const createResultsPngMock = vi.hoisted(() => + vi.fn(async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer) +) + +vi.mock('../lib/performanceTestResultsSvg', async (importOriginal) => ({ + ...(await importOriginal()), + createResultsPng: createResultsPngMock +})) vi.mock('../main', () => ({ i18n: { @@ -148,6 +157,73 @@ const messages = { }, common: { loading: 'Loading…' + }, + settings: { + logs: 'Logs' + }, + devPlatform: { + workspace: { + personalLabel: 'Personal' + } + }, + performanceTest: { + title: 'Performance Tests', + description: 'Run performance tests against your ComfyUI instances.', + selectInstance: '1. Select an instance', + workspaceLabel: 'Workspace', + instanceLabel: 'Instance', + selectInstancePlaceholder: 'Select an instance', + dropWorkflow: '2. Drop a workflow in API format', + dropWorkflowHint: 'Drop a workflow .json file here, or click to browse', + importingWorkflow: 'Importing workflow...', + importFailed: 'Could not import the workflow.', + deleteWorkflow: 'Delete workflow', + deleteFailed: 'Could not delete the workflow.', + run: 'Run', + stop: 'Stop', + stopping: 'Stopping...', + stopFailed: 'Could not stop the instance.', + measurementSettings: '3. Set measurements settings', + warmupRuns: 'Warm-up runs', + measuredRuns: 'Measured runs', + logsPlaceholder: 'Instance logs will appear here.', + results: 'Results', + resultsPlaceholder: 'Performance test results will appear here.', + workflowFileName: 'Workflow file', + fastestRun: 'Fastest run', + slowestRun: 'Slowest run', + averageRunDuration: 'Average run', + medianRunDuration: 'Median run', + measuredRunCount: 'Measured runs', + failedRunCount: 'Failed runs', + runProgress: 'Progress', + runProgressCount: '{completed} of {total} runs completed', + runDurationChart: 'Run duration aggregates', + device: 'Compute device', + vram: 'VRAM', + ram: 'RAM', + pytorchVersion: 'PyTorch version', + xformersVersion: 'xFormers version', + systemInformation: 'System information', + cpu: 'CPU', + cpuCores: 'CPU cores', + operatingSystem: 'Operating system', + architecture: 'Architecture', + openResultsFolder: 'Open folder', + imageTitle: 'Performance Test: {workflowName}', + exportResultsImage: 'Export results', + exportingImage: 'Exporting image...', + exportImageFailed: 'Could not export the results image.', + running: 'Running...', + launchFailed: 'Failed to start the instance.', + submittingRuns: 'Submitting {warmupCount} warm-up runs and {count} measured runs...', + completedRuns: + 'Finished {count} measured runs ({failed} failed). Final response saved to {path}', + submitFailed: 'Failed to submit the performance test workflow.' + }, + benchmarks: { + title: 'Benchmarks', + description: 'Browse and compare results from your performance tests.' } } } @@ -161,6 +237,11 @@ interface InstallationLike { name: string sourceLabel: string sourceCategory: string + sourceId?: string + workspaceId?: string + status?: string + version?: string + statusTag?: { style: string; label: string; detail?: string } } type PanelTriggerPayload = { @@ -177,14 +258,23 @@ type PanelTriggerPayload = { interface MockApiState { comfybuilder: { getAuthStatus: ReturnType + signIn: ReturnType + signOut: ReturnType onAuthChanged: ReturnType listWorkspaces: ReturnType + listBuilds: ReturnType + switchWorkspace: ReturnType } panelSwitchCallbacks: ((data: { panel: string; installationId?: string }) => void)[] panelTriggerOverlayCallbacks: ((data: PanelTriggerPayload) => void)[] appUpdatePromptRestartCallbacks: ((data: { version: string }) => void)[] appUpdateUserActionFailedCallbacks: ((data: { message: string }) => void)[] installationsChangedCallbacks: (() => void)[] + performanceTestProgressCallbacks: ((data: { + sessionId: string + completedRuns: number + totalRuns: number + }) => void)[] /** File-menu Skip Onboarding callbacks. Main fires this when the * user clicks the entry in the waffle popup; tests can simulate * the click by invoking each callback. */ @@ -217,15 +307,25 @@ function installMockApi(initial?: { const installations: InstallationLike[] = initial?.installations ?? [] const state: MockApiState = { comfybuilder: { - getAuthStatus: vi.fn().mockResolvedValue({ signedIn: false }), + getAuthStatus: vi.fn().mockResolvedValue({ + signedIn: true, + workspaceId: 'workspace-1', + workspaceName: 'Workspace One', + workspaceType: 'team' + }), + signIn: vi.fn(async () => ({ signedIn: true })), + signOut: vi.fn(async () => ({ signedIn: false })), onAuthChanged: vi.fn(() => () => {}), - listWorkspaces: vi.fn().mockResolvedValue([]) + listWorkspaces: vi.fn().mockResolvedValue([]), + listBuilds: vi.fn().mockResolvedValue([]), + switchWorkspace: vi.fn(async () => ({ signedIn: true })) }, panelSwitchCallbacks: [], panelTriggerOverlayCallbacks: [], appUpdatePromptRestartCallbacks: [], appUpdateUserActionFailedCallbacks: [], installationsChangedCallbacks: [], + performanceTestProgressCallbacks: [], firstUseSkipCallbacks: [], openFeedbackCallbacks: [], closeRequestCallbacks: [], @@ -238,6 +338,69 @@ function installMockApi(initial?: { installUpdate: vi.fn(async () => {}), downloadUpdate: vi.fn(async () => {}) } + const persistedResultsSummary = { + createdAt: '2026-09-07T22:56:00.000Z', + instance: { id: 'workspace-install', name: 'Workspace Install' }, + workspace: { id: 'workspace-1', name: 'Workspace One' }, + workflowName: 'cat-workflow.json', + fastestJobDurationSeconds: 1.25, + slowestJobDurationSeconds: 2.75, + averageJobDurationSeconds: 2, + medianJobDurationSeconds: 1.875, + measuredJobCount: 5, + failedRunCount: 0, + hardware: { + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'Top-level fallback should not be displayed', + backend: 'native', + devices: [ + { + deviceType: 'cuda', + deviceIndex: 0, + deviceName: 'NVIDIA GeForce RTX 4090', + backend: 'native' + } + ], + vramMb: 24576, + ramMb: 65461, + pytorchVersion: '2.10.0+cu130', + xformersVersion: '0.0.31', + cudaDeviceSet: 0 + }, + systemInfo: { + gpu_vendor: 'nvidia', + gpu_label: 'NVIDIA', + gpu_model: 'NVIDIA GeForce RTX 4090', + gpu_vram_mb: 24576, + gpu_vram_gb: 24, + gpu_tier: 'high', + gpus: [], + nvidia_driver_version: '580.88', + nvidia_driver_supported: true, + amd_driver_version: null, + intel_driver_version: null, + platform: 'win32', + arch: 'x64', + os_version: '10.0.26200', + os_distro: 'Microsoft Windows 11 Pro', + os_release: '10.0.26200', + os_arch: '64-bit', + electron_version: '37.2.3', + chrome_version: '138.0.7204.100', + total_memory_gb: 64, + cpu_model: 'AMD Ryzen 9 7950X', + cpu_cores: 32, + cpu_physical_cores: 16, + cpu_speed_ghz: 4.5, + cpu_manufacturer: 'AMD', + app_version: '1.0.47', + auto_update: true, + locale: 'en', + installation_count: 1, + installations: [] + } + } as const const api = { comfybuilder: state.comfybuilder, getLocaleMessages: vi.fn().mockResolvedValue(messages.en), @@ -305,6 +468,8 @@ function installMockApi(initial?: { onInstanceStarted: vi.fn(() => () => {}), onInstanceStopped: vi.fn(() => () => {}), onInstanceStopping: vi.fn(() => () => {}), + stopComfyUI: vi.fn(async () => {}), + cancelOperation: vi.fn(async () => {}), onComfyOutput: vi.fn(() => () => {}), onComfyExited: vi.fn(() => () => {}), onInstanceCrashed: vi.fn(() => () => {}), @@ -339,6 +504,81 @@ function installMockApi(initial?: { // Picker thumbnail warm-up fetches the bundled-template options on the // first-use cold-start path; returning users must never trigger it. getFieldOptions: vi.fn(async () => []), + getPathForFile: vi.fn((file: File) => `C:\\incoming\\${file.name}`), + importPerformanceTestWorkflow: vi.fn(async () => ({ + ok: true, + filePath: 'C:\\ComfyUI\\performance-tests\\20260907225500\\cat-workflow.json' + })), + deletePerformanceTestWorkflow: vi.fn(async () => ({ ok: true })), + listPerformanceTestBenchmarks: vi.fn(async () => ({ folderPath: '', benchmarks: [] })), + onPerformanceTestProgress: vi.fn((cb) => { + state.performanceTestProgressCallbacks.push(cb) + return () => { + state.performanceTestProgressCallbacks = state.performanceTestProgressCallbacks.filter( + (callback) => callback !== cb + ) + } + }), + runPerformanceTestWorkflow: vi.fn( + async (_sessionId: string, _filePath: string, measuredRuns: number, warmupRuns: number) => ({ + ok: true, + submitted: measuredRuns, + preparationRuns: warmupRuns, + totalSubmitted: measuredRuns + warmupRuns, + promptIds: Array.from( + { length: measuredRuns + warmupRuns }, + (_, index) => `prompt-${index + 1}` + ), + resultPath: 'C:\\ComfyUI\\performance-tests\\20260907225600\\jobs.json', + resultsSummaryPath: 'C:\\ComfyUI\\performance-tests\\20260907225600\\results.json', + failedRuns: 0, + statistics: { + fastest: { jobId: 'prompt-3', durationSeconds: 1.25 }, + slowest: { jobId: 'prompt-7', durationSeconds: 2.75 }, + averageDurationSeconds: 2, + medianDurationSeconds: 1.875, + measuredJobCount: measuredRuns + }, + hardware: persistedResultsSummary.hardware, + systemInfo: persistedResultsSummary.systemInfo, + resultsSummary: persistedResultsSummary + }) + ), + savePerformanceTestLogs: vi.fn(async () => ({ + ok: true, + logsPath: 'C:\\ComfyUI\\performance-tests\\20260907225600\\logs.txt' + })), + readPerformanceTestResultsSummary: vi.fn(async () => ({ + ...persistedResultsSummary, + createdAt: '2001-02-03T04:05:00.000Z', + workflowName: 'results-json-workflow.json', + fastestJobDurationSeconds: 9.1, + slowestJobDurationSeconds: 12.3, + averageJobDurationSeconds: 10.2, + medianJobDurationSeconds: 10, + measuredJobCount: 7, + failedRunCount: 2, + hardware: { + ...persistedResultsSummary.hardware, + devices: [ + { + ...persistedResultsSummary.hardware.devices[0], + deviceName: 'Results JSON GPU' + } + ] + }, + systemInfo: { + ...persistedResultsSummary.systemInfo, + cpu_model: 'Results JSON CPU', + os_distro: 'Results JSON OS', + os_release: '1.0' + } + })), + openPath: vi.fn(async () => {}), + exportResultsImage: vi.fn(async () => ({ + ok: true, + filePath: 'C:\\Exports\\performance-test-results.png' + })), openGlobalSettings: vi.fn(), openInstancePicker: vi.fn() } @@ -430,6 +670,487 @@ describe('PanelApp', () => { expect(lifecycle.attributes('data-installation-id')).toBe('test-id') }) + it('renders the benchmarks body with its branded introduction', async () => { + window.history.replaceState({}, '', '/?panel=benchmarks&firstUseCompleted=true') + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.find('[data-testid="benchmarks"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="benchmarks-logo"]').exists()).toBe(true) + expect(wrapper.get('.branded-page-header h1').text()).toBe('Benchmarks') + expect(wrapper.get('.branded-page-header__description').text()).toBe( + 'Browse and compare results from your performance tests.' + ) + }) + + it('preserves the dashboard workspace when opening Performance Test', async () => { + mockState.installations = [ + { ...SAMPLE_INSTALL, id: 'workspace-1-install', workspaceId: 'workspace-1' }, + { ...SAMPLE_INSTALL, id: 'workspace-2-install', workspaceId: 'workspace-2' } + ] + const listWorkspaces = window.api.comfybuilder.listWorkspaces as ReturnType + listWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Workspace One', type: 'team' }, + { id: 'workspace-2', name: 'Workspace Two', type: 'team' } + ]) + window.history.replaceState({}, '', '/?panel=chooser&firstUseCompleted=true') + const wrapper = mountPanel() + await flushPromises() + + const dashboardScope = useDashboardScopeStore() + dashboardScope.selectWorkspace('workspace-2') + + mockState.panelSwitchCallbacks.forEach((callback) => callback({ panel: 'performance-test' })) + await flushPromises() + + expect( + wrapper.get('.performance-test__workspace-select .workspace-selector__name').text() + ).toBe('Workspace Two') + }) + + it('shows only installed Personal instances on Performance Test while signed out', async () => { + mockState.comfybuilder.getAuthStatus.mockResolvedValue({ signedIn: false }) + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'personal-installed', + name: 'Personal Installed', + status: 'installed' + }, + { + ...SAMPLE_INSTALL, + id: 'personal-failed', + name: 'Personal Failed', + status: 'failed' + }, + { + ...SAMPLE_INSTALL, + id: 'team-installed', + name: 'Team Installed', + status: 'installed', + workspaceId: 'workspace-1' + } + ] + window.history.replaceState({}, '', '/?panel=performance-test&firstUseCompleted=true') + + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.find('.performance-test__content').exists()).toBe(true) + expect( + wrapper.get('.performance-test__workspace-select .workspace-selector__name').text() + ).toBe('Personal') + + await wrapper.get('.performance-test__instance-select button').trigger('click') + await flushPromises() + expect( + Array.from(document.querySelectorAll('.ui-select-option-label')).map( + (option) => option.textContent + ) + ).toEqual(['Personal Installed']) + }) + + it('renders the performance test body with scoped instance rows', async () => { + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + version: '0.3.50', + statusTag: { style: 'update', label: 'Update to 0.3.51' }, + workspaceId: 'workspace-1' + }, + { + ...SAMPLE_INSTALL, + id: 'migrate-install', + name: 'Legacy Install', + sourceId: 'legacy-desktop', + sourceCategory: 'local', + status: 'installed', + statusTag: { style: 'migrate', label: 'Migrate' }, + workspaceId: 'workspace-1' + }, + { + ...SAMPLE_INSTALL, + id: 'danger-install', + name: 'Missing Install', + sourceId: 'standalone', + sourceCategory: 'local', + status: 'installed', + statusTag: { + style: 'danger', + label: 'Folder Not Found', + detail: 'The instance folder could not be found.' + }, + workspaceId: 'workspace-1' + }, + { + ...SAMPLE_INSTALL, + id: 'other-workspace-install', + name: 'Other Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-2' + }, + { + ...SAMPLE_INSTALL, + id: 'unmanaged-install', + name: 'Unmanaged Install', + sourceId: 'standalone', + status: 'installed' + }, + { + ...SAMPLE_INSTALL, + id: 'cloud-install', + name: 'Comfy Cloud Instance', + sourceId: 'cloud', + sourceLabel: 'Comfy Cloud', + sourceCategory: 'cloud', + status: 'installed' + } + ] + const listWorkspaces = window.api.comfybuilder.listWorkspaces as ReturnType + listWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Workspace One', type: 'team' }, + { id: 'workspace-2', name: 'Workspace Two', type: 'team' } + ]) + window.history.replaceState({}, '', '/?panel=performance-test&firstUseCompleted=true') + const wrapper = mountPanel() + await flushPromises() + + expect(wrapper.find('[data-testid="performance-test"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="performance-test-logo"]').exists()).toBe(true) + expect(wrapper.get('.branded-page-header h1').text()).toBe('Performance Tests') + expect(wrapper.find('.performance-test__account').exists()).toBe(true) + expect(wrapper.findAll('.performance-test__selection-row')).toHaveLength(2) + expect( + wrapper.findAll('.performance-test__selection-label').map((label) => label.text()) + ).toEqual(['Workspace', 'Instance']) + expect(wrapper.findAll('.performance-test__selection-control button')).toHaveLength(2) + expect(wrapper.find('.performance-test__workspace-select .ui-select-trigger').exists()).toBe( + false + ) + expect( + wrapper.get('.performance-test__workspace-select .workspace-selector__name').text() + ).toBe('Workspace One') + expect(wrapper.find('.performance-test__columns').exists()).toBe(true) + expect(wrapper.findAll('.performance-test__column')).toHaveLength(3) + expect( + wrapper.findAll('.performance-test__column h2').map((heading) => heading.text()) + ).toEqual([ + '1. Select an instance', + '2. Drop a workflow in API format', + '3. Set measurements settings' + ]) + const settings = wrapper.findAll('.performance-test__setting') + expect(settings).toHaveLength(2) + expect(settings.map((setting) => setting.find('label').text())).toEqual([ + 'Warm-up runs', + 'Measured runs' + ]) + const warmupRunsInput = settings[0]!.get('input') + expect(warmupRunsInput.element).toHaveProperty('value', '1') + expect(warmupRunsInput.attributes()).toMatchObject({ min: '1', max: '5', step: '1' }) + await warmupRunsInput.setValue('6') + expect(warmupRunsInput.element).toHaveProperty('value', '5') + await warmupRunsInput.setValue('0') + expect(warmupRunsInput.element).toHaveProperty('value', '1') + await warmupRunsInput.setValue('') + expect(warmupRunsInput.element).toHaveProperty('value', '1') + await warmupRunsInput.setValue('3') + expect(warmupRunsInput.element).toHaveProperty('value', '3') + const measuredRunsInput = settings[1]!.get('input') + expect(measuredRunsInput.element).toHaveProperty('value', '5') + expect(measuredRunsInput.attributes()).toMatchObject({ + min: '1', + max: '100', + step: '1' + }) + await measuredRunsInput.setValue('101') + expect(measuredRunsInput.element).toHaveProperty('value', '100') + await measuredRunsInput.setValue('0') + expect(measuredRunsInput.element).toHaveProperty('value', '1') + await measuredRunsInput.setValue('4.6') + expect(measuredRunsInput.element).toHaveProperty('value', '5') + await measuredRunsInput.setValue('') + expect(measuredRunsInput.element).toHaveProperty('value', '5') + const logsToggle = wrapper.get('.performance-test__logs-section button') + expect(logsToggle.text()).toBe('Logs') + expect(logsToggle.attributes('aria-expanded')).toBe('true') + const resultsToggle = wrapper.get('.performance-test__results-section button') + expect(resultsToggle.text()).toBe('Results') + expect(resultsToggle.attributes('aria-expanded')).toBe('true') + expect( + wrapper.findAll('.performance-test__content > section').map((section) => section.classes()[0]) + ).toEqual(['performance-test__results-section', 'performance-test__logs-section']) + expect(wrapper.get('.performance-test__results').text()).toContain( + 'Performance test results will appear here.' + ) + await resultsToggle.trigger('click') + expect(resultsToggle.attributes('aria-expanded')).toBe('false') + expect(wrapper.get('.performance-test__results').attributes('style')).toContain('display: none') + await resultsToggle.trigger('click') + expect( + wrapper.get('.performance-test__column:nth-child(3) .performance-test__run').exists() + ).toBe(true) + expect(wrapper.find('.performance-test__drop-zone').text()).toBe( + 'Drop a workflow .json file here, or click to browse' + ) + expect(wrapper.find('.performance-test__run').text()).toBe('Run') + expect(wrapper.find('.performance-test__logs').text()).toBe('Instance logs will appear here.') + await logsToggle.trigger('click') + expect(logsToggle.attributes('aria-expanded')).toBe('false') + expect(wrapper.get('.performance-test__logs').attributes('style')).toContain('display: none') + await logsToggle.trigger('click') + const instanceSelect = wrapper.get('.performance-test__instance-select button') + expect(instanceSelect.attributes()).toMatchObject({ + role: 'combobox', + 'aria-label': 'Select an instance', + 'aria-expanded': 'false' + }) + expect(instanceSelect.text()).toBe('Select an instance') + await instanceSelect.trigger('click') + await flushPromises() + expect( + Array.from(document.querySelectorAll('.ui-select-option-label')).map( + (option) => option.textContent + ) + ).toEqual(['Workspace Install', 'Legacy Install', 'Missing Install']) + expect( + Array.from(document.querySelectorAll('.ui-select-option-desc')).map( + (option) => option.textContent + ) + ).toEqual(['Standalone · 0.3.50', 'Standalone', 'Standalone']) + expect(wrapper.text()).not.toContain('Other Workspace Install') + expect(wrapper.find('.branded-page-header__description').text()).toBe( + 'Run performance tests against your ComfyUI instances.' + ) + expect(wrapper.find('[data-testid="chooser-view"]').exists()).toBe(false) + ;(document.querySelectorAll('.ui-select-option')[0] as HTMLElement).click() + await flushPromises() + expect(instanceSelect.text()).toBe('Workspace Install') + + const api = ( + window as unknown as { + api: { + openInstancePicker: ReturnType + importPerformanceTestWorkflow: ReturnType + deletePerformanceTestWorkflow: ReturnType + runPerformanceTestWorkflow: ReturnType + getPathForFile: ReturnType + runAction: ReturnType + stopComfyUI: ReturnType + cancelOperation: ReturnType + onInstanceStarted: ReturnType + onInstanceStopped: ReturnType + onComfyOutput: ReturnType + savePerformanceTestLogs: ReturnType + } + } + ).api + await wrapper.get('.performance-test__drop-content').trigger('click') + await flushPromises() + expect(api.importPerformanceTestWorkflow).toHaveBeenCalledWith(undefined) + expect(wrapper.get('.performance-test__workflow-file').text()).toContain('cat-workflow.json') + expect(wrapper.get('.performance-test__workflow-file').text()).toContain( + 'C:\\ComfyUI\\performance-tests\\20260907225500\\cat-workflow.json' + ) + expect(wrapper.get('.performance-test__drop-zone').text()).not.toContain( + 'Drop a workflow .json file here, or click to browse' + ) + + api.importPerformanceTestWorkflow.mockResolvedValueOnce({ + ok: true, + filePath: 'C:\\ComfyUI\\performance-tests\\20260907225600\\cat-workflow.json' + }) + const droppedFile = new File(['{}'], 'dropped.json', { type: 'application/json' }) + await wrapper.get('.performance-test__drop-zone').trigger('drop', { + dataTransfer: { files: [droppedFile] } + }) + await flushPromises() + expect(api.getPathForFile).toHaveBeenCalledWith(droppedFile) + expect(api.importPerformanceTestWorkflow).toHaveBeenLastCalledWith('C:\\incoming\\dropped.json') + expect(wrapper.get('.performance-test__workflow-file').text()).toContain( + 'C:\\ComfyUI\\performance-tests\\20260907225600\\cat-workflow.json' + ) + + await wrapper.get('.performance-test__run').trigger('click') + await flushPromises() + expect(api.runAction).toHaveBeenCalledWith('workspace-install', 'launch', { + launchModeOverride: 'console', + autoPortOnConflict: true, + sessionIdOverride: 'performance-test:workspace-install' + }) + expect(api.runPerformanceTestWorkflow).toHaveBeenCalledWith( + 'performance-test:workspace-install', + 'C:\\ComfyUI\\performance-tests\\20260907225600\\cat-workflow.json', + 5, + 3 + ) + expect(wrapper.get('.performance-test__logs').text()).toContain( + 'Submitting 3 warm-up runs and 5 measured runs...' + ) + expect(wrapper.get('.performance-test__logs').text()).toContain( + 'Finished 5 measured runs (0 failed). Final response saved to ' + ) + expect(wrapper.get('.performance-test__logs').text()).toContain( + 'C:\\ComfyUI\\performance-tests\\20260907225600\\jobs.json' + ) + expect(api.savePerformanceTestLogs).toHaveBeenCalledWith( + 'C:\\ComfyUI\\performance-tests\\20260907225600\\cat-workflow.json', + expect.stringContaining('Submitting 3 warm-up runs and 5 measured runs...') + ) + expect(api.savePerformanceTestLogs.mock.calls[0]![1]).toContain( + 'Finished 5 measured runs (0 failed). Final response saved to ' + ) + const results = wrapper.get('.performance-test__results').text() + expect(results).toContain('Workflow filecat-workflow.json') + expect(results).toContain('Fastest run') + expect(results).toContain('1.250 s') + expect(results).toContain('Slowest run') + expect(results).toContain('2.750 s') + expect(results).not.toContain('prompt-3') + expect(results).not.toContain('prompt-7') + expect(results).toContain('Average run') + expect(results).toContain('2.000 s') + expect(results).toContain('Median run') + expect(results).toContain('1.875 s') + expect(results).toContain('Measured runs') + expect(results).toContain('5') + expect(results).toContain('Failed runs') + expect(results).toContain('0') + expect(wrapper.get('.performance-test__summary').findAll(':scope > *')).toHaveLength(2) + expect( + wrapper + .get('.performance-test__timing-list') + .findAll('dt') + .map((label) => label.text()) + ).toEqual([ + 'Measured runs', + 'Failed runs', + 'Fastest run', + 'Average run', + 'Slowest run', + 'Median run' + ]) + expect(wrapper.get('.performance-test__aggregate-chart').attributes('aria-label')).toBe( + 'Run duration aggregates' + ) + expect(wrapper.findAll('.performance-test__aggregate-bar')).toHaveLength(4) + expect(wrapper.findAll('.performance-test__results h3')).toHaveLength(1) + expect(results).toContain('System information') + expect(results).toContain('NVIDIA GeForce RTX 4090') + expect(results).not.toContain('Top-level fallback should not be displayed') + expect(wrapper.find('.performance-test__result-list--compact').exists()).toBe(true) + expect(wrapper.findAll('.performance-test__system-group h4')).toHaveLength(0) + const systemGroups = wrapper.findAll('.performance-test__system-group') + expect(systemGroups[0]!.text()).toContain('NVIDIA GeForce RTX 4090') + expect(systemGroups[0]!.text()).toContain('VRAM24.0 GB') + expect(systemGroups[0]!.text()).toContain('RAM63.9 GB') + expect(systemGroups[0]!.text()).toContain('PyTorch version2.10.0+cu130') + expect(systemGroups[0]!.text()).toContain('xFormers version0.0.31') + expect(systemGroups[1]!.text()).toContain('CPUAMD Ryzen 9 7950X') + expect(systemGroups[1]!.text()).toContain('CPU cores32') + expect(systemGroups[1]!.text()).toContain('Architecturex64') + expect(systemGroups[1]!.text()).toContain('Operating systemMicrosoft Windows 11 Pro 10.0.26200') + expect(results).not.toContain('GPU driver') + expect(results).not.toContain('Device index') + expect(results).not.toContain('Backend') + expect(results).not.toContain('24576 MB') + expect(results).not.toContain('65461 MB') + expect( + wrapper + .get('.performance-test__results') + .element.lastElementChild?.classList.contains('performance-test__results-actions') + ).toBe(true) + const openResultsFolder = wrapper.get('.performance-test__open-results') + expect(openResultsFolder.text()).toBe('Open folder') + await openResultsFolder.trigger('click') + expect(api.openPath).toHaveBeenCalledWith('C:\\ComfyUI\\performance-tests\\20260907225600') + const exportResultsImage = wrapper.get('.performance-test__export-results') + expect(exportResultsImage.text()).toBe('Export results') + await exportResultsImage.trigger('click') + await flushPromises() + expect(api.readPerformanceTestResultsSummary).toHaveBeenCalledWith( + 'C:\\ComfyUI\\performance-tests\\20260907225600\\results.json' + ) + expect(api.exportResultsImage).toHaveBeenCalledTimes(1) + const [png, imageType, defaultPath] = api.exportResultsImage.mock.calls[0]! + expect(imageType).toBe('performance-test') + expect(defaultPath).toBe('C:\\ComfyUI\\performance-tests\\20260907225600') + expect(png).toBeInstanceOf(ArrayBuffer) + expect(createResultsPngMock).toHaveBeenCalledTimes(1) + const svg = createResultsPngMock.mock.calls[0]![0] + expect(svg).toContain(' callback({ panel: 'chooser' })) + await flushPromises() + expect(wrapper.find('[data-testid="performance-test"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="chooser-view"]').exists()).toBe(true) + + mockState.panelSwitchCallbacks.forEach((callback) => callback({ panel: 'performance-test' })) + await flushPromises() + expect(wrapper.get('.performance-test__results').text()).toContain('1.250 s') + expect(wrapper.get('.performance-test__workflow-file').text()).toContain('cat-workflow.json') + + const outputCallback = api.onComfyOutput.mock.calls[0]![0] as (data: { + installationId: string + text: string + }) => void + outputCallback({ + installationId: 'performance-test:workspace-install', + text: 'ComfyUI is ready\n' + }) + await flushPromises() + expect(wrapper.get('.performance-test__logs').text()).toContain('ComfyUI is ready') + expect(wrapper.get('.performance-test__stop').attributes('disabled')).toBe('') + expect(api.stopComfyUI).toHaveBeenCalledWith('performance-test:workspace-install') + expect(api.cancelOperation).not.toHaveBeenCalled() + + await wrapper.get('.performance-test__delete-workflow').trigger('click') + await flushPromises() + expect(api.deletePerformanceTestWorkflow).toHaveBeenCalledWith( + 'C:\\ComfyUI\\performance-tests\\20260907225600\\cat-workflow.json' + ) + expect(wrapper.find('.performance-test__workflow-file').exists()).toBe(false) + expect(wrapper.get('.performance-test__drop-zone').text()).toContain( + 'Drop a workflow .json file here, or click to browse' + ) + + await wrapper.get('.performance-test__workspace-select button').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="devplatform-workspace-personal"]').trigger('click') + await flushPromises() + + expect(instanceSelect.text()).toBe('Select an instance') + await instanceSelect.trigger('click') + await flushPromises() + expect( + Array.from(document.querySelectorAll('.ui-select-option-label')).map( + (option) => option.textContent + ) + ).toEqual(['Unmanaged Install']) + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + }) + it('opens the new-install takeover above the chooser body when show-new-install fires', async () => { // Flow modals are Tier 3 takeover overlays. The chooser stays // mounted underneath the takeover, so dismissing the takeover @@ -559,6 +1280,218 @@ describe('PanelApp', () => { }) }) + it('starts a separate performance test process when the normal instance is running', async () => { + mockState.comfybuilder.listWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Workspace One', type: 'team' } + ]) + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-1' + } + ] + const api = ( + window as unknown as { + api: { + getRunningInstances: ReturnType + stopComfyUI: ReturnType + runAction: ReturnType + } + } + ).api + api.getRunningInstances.mockResolvedValueOnce([ + { + installationId: 'workspace-install', + installationName: 'Workspace Install', + mode: 'window' + } + ]) + window.history.replaceState({}, '', '/?panel=performance-test&firstUseCompleted=true') + + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('.performance-test__drop-content').trigger('click') + await wrapper.get('.performance-test__instance-select button').trigger('click') + await flushPromises() + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + const runButton = wrapper.get('.performance-test__run') + expect(runButton.attributes('disabled')).toBeUndefined() + + await runButton.trigger('click') + await flushPromises() + + expect(api.stopComfyUI).not.toHaveBeenCalledWith('workspace-install') + expect(api.runAction).toHaveBeenCalledWith('workspace-install', 'launch', { + launchModeOverride: 'console', + autoPortOnConflict: true, + sessionIdOverride: 'performance-test:workspace-install' + }) + }) + + it('restarts an already-running performance test session before running', async () => { + mockState.comfybuilder.listWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Workspace One', type: 'team' } + ]) + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-1' + } + ] + const api = ( + window as unknown as { + api: { + getRunningInstances: ReturnType + stopComfyUI: ReturnType + runAction: ReturnType + runPerformanceTestWorkflow: ReturnType + } + } + ).api + api.getRunningInstances.mockResolvedValueOnce([ + { + installationId: 'performance-test:workspace-install', + installationName: 'Workspace Install', + mode: 'console' + } + ]) + window.history.replaceState({}, '', '/?panel=performance-test&firstUseCompleted=true') + + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('.performance-test__drop-content').trigger('click') + await wrapper.get('.performance-test__instance-select button').trigger('click') + await flushPromises() + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + + const runButton = wrapper.get('.performance-test__run') + expect(runButton.attributes('disabled')).toBeUndefined() + await runButton.trigger('click') + await flushPromises() + + expect(api.stopComfyUI).toHaveBeenCalledWith('performance-test:workspace-install') + expect(api.stopComfyUI.mock.invocationCallOrder[0]).toBeLessThan( + api.runAction.mock.invocationCallOrder[0]! + ) + expect(api.runAction).toHaveBeenCalledWith('workspace-install', 'launch', { + launchModeOverride: 'console', + autoPortOnConflict: true, + sessionIdOverride: 'performance-test:workspace-install' + }) + expect(api.runPerformanceTestWorkflow).toHaveBeenCalledWith( + 'performance-test:workspace-install', + 'C:\\ComfyUI\\performance-tests\\20260907225500\\cat-workflow.json', + 5, + 1 + ) + }) + + it('allows a crashed performance test session to be stopped and cleared manually', async () => { + mockState.comfybuilder.listWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Workspace One', type: 'team' } + ]) + mockState.installations = [ + { + ...SAMPLE_INSTALL, + id: 'workspace-install', + name: 'Workspace Install', + sourceId: 'standalone', + status: 'installed', + workspaceId: 'workspace-1' + } + ] + const api = ( + window as unknown as { + api: { + runPerformanceTestWorkflow: ReturnType + stopComfyUI: ReturnType + onComfyExited: ReturnType + } + } + ).api + let resolvePerformanceTest!: (result: { + ok: boolean + submitted: number + preparationRuns: number + totalSubmitted: number + message: string + }) => void + api.runPerformanceTestWorkflow.mockImplementationOnce( + () => new Promise((resolve) => (resolvePerformanceTest = resolve)) + ) + window.history.replaceState({}, '', '/?panel=performance-test&firstUseCompleted=true') + const wrapper = mountPanel() + await flushPromises() + + await wrapper.get('.performance-test__drop-content').trigger('click') + await wrapper.get('.performance-test__instance-select button').trigger('click') + await flushPromises() + ;(document.querySelector('.ui-select-option') as HTMLElement).click() + await flushPromises() + await wrapper.get('.performance-test__run').trigger('click') + await flushPromises() + expect(wrapper.get('.performance-test__run').text()).toBe('Running...') + + mockState.performanceTestProgressCallbacks.forEach((callback) => + callback({ sessionId: 'different-session', completedRuns: 5, totalRuns: 6 }) + ) + mockState.performanceTestProgressCallbacks.forEach((callback) => + callback({ + sessionId: 'performance-test:workspace-install', + completedRuns: 2, + totalRuns: 6 + }) + ) + await flushPromises() + expect(wrapper.get('.performance-test__progress').text()).toContain('Progress') + expect(wrapper.get('.performance-test__progress').text()).toContain('2 of 6 runs completed') + const progressBar = wrapper.get('[role="progressbar"]') + expect(progressBar.attributes('aria-valuenow')).toBe('2') + expect(progressBar.attributes('aria-valuemax')).toBe('6') + expect(progressBar.get('i').attributes('style')).toContain('width: 33%') + + const exitedCallback = api.onComfyExited.mock.calls[0]![0] as (data: { + installationId: string + installationName: string + crashed: boolean + exitCode: number + }) => void + exitedCallback({ + installationId: 'performance-test:workspace-install', + installationName: 'Workspace Install', + crashed: true, + exitCode: 1 + }) + await flushPromises() + + const stopButton = wrapper.get('.performance-test__stop') + expect(stopButton.attributes('disabled')).toBeUndefined() + await stopButton.trigger('click') + await flushPromises() + expect(api.stopComfyUI).toHaveBeenCalledWith('performance-test:workspace-install') + expect(stopButton.attributes('disabled')).toBe('') + resolvePerformanceTest({ + ok: false, + submitted: 0, + preparationRuns: 0, + totalSubmitted: 0, + message: 'The performance test instance exited.' + }) + await flushPromises() + }) + it('returns to the underlying body when a takeover emits close', async () => { window.history.replaceState({}, '', '/?panel=new-install&firstUseCompleted=true') const wrapper = mountPanel() diff --git a/src/renderer/src/panel/PanelApp.vue b/src/renderer/src/panel/PanelApp.vue index 944b90385..ff6245b5d 100644 --- a/src/renderer/src/panel/PanelApp.vue +++ b/src/renderer/src/panel/PanelApp.vue @@ -8,6 +8,8 @@ import FeedbackModal from '../components/FeedbackModal.vue' import AnnouncementModal from '../components/AnnouncementModal.vue' import ComfyLifecycleView from './ComfyLifecycleView.vue' import ChooserView from '../views/ChooserView.vue' +import PerformanceTestView from '../views/PerformanceTestView.vue' +import BenchmarksView from '../views/BenchmarksView.vue' import InstallWizardModal from '../views/InstallWizardModal.vue' import TrackModal from '../views/TrackModal.vue' import LoadSnapshotModal from '../views/LoadSnapshotModal.vue' @@ -603,6 +605,17 @@ onUnmounted(() => { @show-progress="handleShowProgress" /> + +
+ +
+ + + + @@ -721,7 +734,9 @@ body.panel-overlay-mode .panel-shell { * so its grid can scroll vertically) — negate the panel-content * gutter for those branches. */ .panel-content:has(.panel-comfy-lifecycle), -.panel-content:has(.panel-chooser) { +.panel-content:has(.panel-chooser), +.panel-content:has(.panel-performance-test), +.panel-content:has(.panel-benchmarks) { padding: 0; } @@ -737,7 +752,9 @@ body.panel-overlay-mode .panel-shell { } .panel-comfy-lifecycle, -.panel-chooser { +.panel-chooser, +.panel-performance-test, +.panel-benchmarks { flex: 1; min-height: 0; display: flex; diff --git a/src/renderer/src/panel/usePanelOverlays.ts b/src/renderer/src/panel/usePanelOverlays.ts index bab9621a5..9a301e6b3 100644 --- a/src/renderer/src/panel/usePanelOverlays.ts +++ b/src/renderer/src/panel/usePanelOverlays.ts @@ -21,6 +21,8 @@ export type PanelKey = | 'comfy' | 'comfy-lifecycle' | 'chooser' + | 'performance-test' + | 'benchmarks' | 'feedback' | 'new-install' | 'track' @@ -45,6 +47,8 @@ const VALID_PANELS: ReadonlySet = new Set([ 'comfy', 'comfy-lifecycle', 'chooser', + 'performance-test', + 'benchmarks', 'feedback', 'new-install', 'track', diff --git a/src/renderer/src/stores/authStore.ts b/src/renderer/src/stores/authStore.ts index 7e4a38a87..4b78f093f 100644 --- a/src/renderer/src/stores/authStore.ts +++ b/src/renderer/src/stores/authStore.ts @@ -2,7 +2,7 @@ import { computed, onScopeDispose, ref } from 'vue' import { defineStore } from 'pinia' import type { AuthStatus, ElectronApi, Workspace } from '../../../types/ipc' -import { isPersonalWorkspace } from '../../../shared/workspaces' +import { isPersonalWorkspace, PERSONAL_WORKSPACE_ID } from '../../../shared/workspaces' import type { Build } from '../devplatform/types' /** @@ -20,6 +20,8 @@ export const useAuthStore = defineStore('auth', () => { const statusLoaded = ref(false) const workspaces = ref([]) const builds = ref([]) + /** Workspace currently selected in workspace-scoped renderer surfaces. */ + const selectedWorkspaceId = ref(PERSONAL_WORKSPACE_ID) const loadingWorkspaces = ref(false) /** Distinguishes unknown membership from a successfully fetched empty catalog. */ const workspacesLoaded = ref(false) @@ -36,6 +38,18 @@ export const useAuthStore = defineStore('auth', () => { /** Bumped on every authoritative status change (push, sign-in, switch, * sign-out) so a slower in-flight pull can never overwrite a newer status. */ let revision = 0 + let workspaceContextInitialized = false + + function initializeWorkspaceContext(workspaceId?: string): void { + if (workspaceContextInitialized) return + selectedWorkspaceId.value = workspaceId ?? PERSONAL_WORKSPACE_ID + workspaceContextInitialized = true + } + + function resetWorkspaceContext(): void { + selectedWorkspaceId.value = PERSONAL_WORKSPACE_ID + workspaceContextInitialized = false + } /** Advance the revision on an authoritative status change. Every in-flight * fetch becomes stale, and a stale fetch's guarded `finally` refuses to @@ -71,8 +85,10 @@ export const useAuthStore = defineStore('auth', () => { } advanceRevision() status.value = next - if (!next.signedIn) resetScopedState() - else { + if (!next.signedIn) { + resetScopedState() + resetWorkspaceContext() + } else { builds.value = [] buildsLoaded.value = false } @@ -197,6 +213,7 @@ export const useAuthStore = defineStore('auth', () => { statusLoaded, workspaces, builds, + selectedWorkspaceId, loadingWorkspaces, workspacesLoaded, loadingBuilds, @@ -205,6 +222,8 @@ export const useAuthStore = defineStore('auth', () => { buildsError, isSignedIn, personalWorkspace, + initializeWorkspaceContext, + resetWorkspaceContext, fetchStatus, whenReady: () => initialStatus, signIn, diff --git a/src/renderer/src/types/ipc.ts b/src/renderer/src/types/ipc.ts index 4330452f7..94a8cfeba 100644 --- a/src/renderer/src/types/ipc.ts +++ b/src/renderer/src/types/ipc.ts @@ -3,6 +3,9 @@ export type { Unsubscribe, Installation, RunningInstance, + PerformanceTestBenchmark, + PerformanceTestResultValue, + PerformanceTestResultsSummary, Source, SourceField, FieldOption, diff --git a/src/renderer/src/views/BenchmarksView.test.ts b/src/renderer/src/views/BenchmarksView.test.ts new file mode 100644 index 000000000..ce4368d9d --- /dev/null +++ b/src/renderer/src/views/BenchmarksView.test.ts @@ -0,0 +1,545 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import type { PerformanceTestBenchmark } from '../types/ipc' +import type * as PerformanceTestResultsSvg from '../lib/performanceTestResultsSvg' +import BaseSelect from '../components/ui/BaseSelect.vue' +import { useDialogs } from '../composables/useDialogs' +import BenchmarksView from './BenchmarksView.vue' + +const createResultsPngMock = vi.hoisted(() => + vi.fn(async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer) +) +const exportResultsImageMock = vi.hoisted(() => + vi.fn(async () => ({ ok: true, filePath: 'C:\\Exports\\benchmark-comparison.png' })) +) +const deletePerformanceTestBenchmarkMock = vi.hoisted(() => vi.fn(async () => ({ ok: true }))) +const renamePerformanceTestBenchmarkMock = vi.hoisted(() => + vi.fn(async (_folderPath: string, _sessionId: string, newSessionId: string) => ({ + ok: true, + sessionId: newSessionId + })) +) + +vi.mock('../lib/performanceTestResultsSvg', async (importOriginal) => ({ + ...(await importOriginal()), + createResultsPng: createResultsPngMock +})) + +vi.mock('./devplatform/DevPlatformAccountChip.vue', () => ({ + default: { template: '
' } +})) + +const messages = { + common: { loading: 'Loading...' }, + benchmarks: { + title: 'Benchmarks', + description: 'Compare results from your performance tests.', + runsLibrary: 'Runs library', + openFolder: 'Select folder', + refresh: 'Refresh sessions', + searchPlaceholder: 'Search runs...', + allWorkspaces: 'All workspaces', + allInstances: 'All instances', + allHardware: 'All hardware', + allWorkflows: 'All workflows', + columns: 'Properties', + columnsToDisplay: 'Columns to display', + unmanagedWorkspace: 'Unmanaged', + unknownHardware: 'Unknown hardware', + session: 'Session', + dateTime: 'Date / Time', + workflow: 'Workflow', + instance: 'Instance', + workspace: 'Workspace', + runs: 'Runs', + hardware: 'GPU / Hardware', + fastest: 'Fastest', + average: 'Average', + median: 'Median', + slowest: 'Slowest', + measuredRuns: 'Measured runs', + selectVisible: 'Select all visible runs', + selectRun: 'Select {workflow}', + actions: 'Actions', + deleteRecord: 'Delete {workflow} benchmark', + deleteConfirmTitle: 'Delete {workflow}?', + deleteConfirmMessage: + 'This will permanently delete all files for session {session}. This cannot be undone.', + deleteFiles: 'Delete files', + deleteErrorTitle: 'Could not delete benchmark', + deleteErrorMessage: 'The benchmark files could not be deleted.', + sessionName: 'Session name', + editSessionName: 'Edit session {session}', + renameErrorTitle: 'Could not rename session', + renameErrorMessage: 'The session folder could not be renamed.', + sessionNameRequired: 'Enter a session name.', + comparison: 'Comparison', + sortComparison: 'Sort comparison', + manualSort: 'Manual sort', + switchSortAscending: 'Switch to ascending sort', + switchSortDescending: 'Switch to descending sort', + reorderComparisonColumn: 'Reorder {workflow} comparison column', + reorderComparisonHint: 'Drag to reorder. You can also press Alt+Left or Alt+Right.', + comparisonImageTitle: 'Benchmark Comparison', + exportResultsImage: 'Export results', + exportingImage: 'Exporting image...', + exportImageFailed: 'Could not export the comparison image.', + metric: 'Metric', + durationRange: 'Duration range (min → max)', + durationRangeHint: 'Range hint', + selectPrompt: 'Select runs from the library to compare them.', + empty: 'No performance test results yet.', + noMatches: 'No runs match these filters.', + loadError: 'Could not load performance test results.' + } +} + +function benchmark( + id: string, + workflowName: string, + average: number, + hardwareName = 'NVIDIA RTX 4090' +): PerformanceTestBenchmark { + const createdAt = `2026-09-${id.padStart(2, '0')}T10:00:00.000Z` + const instance = { id: `instance-${id}`, name: `Instance ${id}` } + const workspace = { id: 'workspace-1', name: 'Comfy' } + return { + id, + createdAt, + instance, + workspace, + workflowName, + fastestJobDurationSeconds: average - 0.4, + slowestJobDurationSeconds: average + 0.7, + averageJobDurationSeconds: average, + medianJobDurationSeconds: average - 0.1, + measuredJobCount: 5, + hardwareName, + result: { + createdAt, + instance, + workspace, + workflowName, + fastestJobDurationSeconds: average - 0.4, + slowestJobDurationSeconds: average + 0.7, + averageJobDurationSeconds: average, + medianJobDurationSeconds: average - 0.1, + measuredJobCount: 5, + hardware: { deviceName: hardwareName }, + systemInfo: { cpu_model: 'Test CPU' }, + customScore: Number(id) + } + } +} + +const sampleBenchmarks = [ + benchmark('13', 'portrait.json', 2), + benchmark('12', 'product.json', 3.4), + benchmark('11', 'portrait.json', 1.2, 'NVIDIA RTX 5090'), + benchmark('10', 'landscape.json', 8.7, 'Apple M3 Max') +] + +function mountView() { + return mount(BenchmarksView, { + global: { + plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: messages } })] + } + }) +} + +describe('BenchmarksView', () => { + beforeEach(() => { + useDialogs().cancel() + createResultsPngMock.mockClear() + exportResultsImageMock.mockClear() + deletePerformanceTestBenchmarkMock.mockClear() + deletePerformanceTestBenchmarkMock.mockResolvedValue({ ok: true }) + renamePerformanceTestBenchmarkMock.mockClear() + ;(window as unknown as { api: object }).api = { + browseFolder: vi.fn(), + exportResultsImage: exportResultsImageMock, + deletePerformanceTestBenchmark: deletePerformanceTestBenchmarkMock, + renamePerformanceTestBenchmark: renamePerformanceTestBenchmarkMock, + listPerformanceTestBenchmarks: vi.fn(async () => ({ + folderPath: 'C:\\results\\performance-tests', + benchmarks: sampleBenchmarks + })) + } + }) + + it('selects the three newest runs and highlights only the best duration in each metric', async () => { + const wrapper = mountView() + await flushPromises() + + expect(wrapper.findAll('[data-testid^="benchmark-row-"]')).toHaveLength(4) + expect(wrapper.findAll('.benchmarks__table thead th').map((header) => header.text())).toEqual([ + '', + 'Workflow', + 'Session', + 'GPU / Hardware', + 'Runs', + 'Date / Time ↓', + '' + ]) + expect(wrapper.get('[data-testid="benchmark-row-13"]').text()).toContain('13') + expect(wrapper.find('.benchmarks__selection-tray').exists()).toBe(false) + expect(wrapper.get('[data-testid="benchmark-row-13"]').attributes('style')).toContain( + '--series-color: #55e0d1' + ) + expect(wrapper.findAll('.benchmarks__best').map((cell) => cell.text())).toEqual([ + '0.8 s', + '1.2 s', + '1.1 s', + '1.9 s' + ]) + expect( + wrapper.findAll('[data-testid^="benchmark-comparison-13-"]').map((field) => field.text()) + ).toEqual(['portrait.json', 'Session: 13', 'GPU / Hardware: NVIDIA RTX 4090']) + expect( + wrapper.findAll('[data-testid^="benchmark-chart-13-"]').map((field) => field.text()) + ).toEqual([ + 'portrait.json', + 'Session: 13', + 'GPU / Hardware: NVIDIA RTX 4090', + '1.6 s', + '2.7 s', + '2 s' + ]) + expect( + wrapper + .get('[data-testid="benchmark-chart-13-slowest-label"]') + .classes('benchmarks__chart-point-label--slowest') + ).toBe(true) + + const exportButton = wrapper.get('.benchmarks__export-results') + expect(exportButton.text()).toBe('Export results') + await exportButton.trigger('click') + await flushPromises() + expect(exportResultsImageMock).toHaveBeenCalledTimes(1) + const [png, imageType, defaultPath] = exportResultsImageMock.mock.calls[0]! + expect(png).toBeInstanceOf(ArrayBuffer) + expect(imageType).toBe('benchmark-comparison') + expect(defaultPath).toBe('C:\\results\\performance-tests') + const svg = createResultsPngMock.mock.calls[0]![0] + expect(svg).toContain('Benchmark Comparison') + expect(svg).toContain('portrait.json') + expect(svg).toContain('Duration range') + expect(svg).toContain('role="img" aria-label="Comfy"') + expect(svg).toContain('class="footer-date"') + expect(svg.match(/class="table-cell best-cell"/g)).toHaveLength(4) + expect(svg).toContain('.best-cell { fill: #f2ff59; fill-opacity: 0.08; }') + expect(svg.match(/class="chart-point-label"/g)).toHaveLength(9) + }) + + it('filters the run library and updates the comparison selection directly', async () => { + const wrapper = mountView() + await flushPromises() + + expect(wrapper.find('[data-testid="benchmark-column-systemInfo.cpu_model"]').exists()).toBe( + true + ) + expect(wrapper.find('[data-testid="benchmark-comparison-13-customScore"]').exists()).toBe(false) + await wrapper.get('[data-testid="benchmark-column-customScore"]').setValue(true) + expect(wrapper.get('.benchmarks__table thead').text()).toContain('Custom Score') + expect(wrapper.get('[data-testid="benchmark-row-13"]').text()).toContain('13') + expect(wrapper.find('[data-testid="benchmark-comparison-13-customScore"]').exists()).toBe(false) + await wrapper.get('[data-testid="benchmark-comparison-column-customScore"]').setValue(true) + expect(wrapper.get('[data-testid="benchmark-comparison-13-customScore"]').text()).toBe( + 'Custom Score: 13' + ) + expect(wrapper.get('[data-testid="benchmark-comparison-12-customScore"]').text()).toBe( + 'Custom Score: 12' + ) + expect(wrapper.get('[data-testid="benchmark-chart-13-customScore"]').text()).toBe( + 'Custom Score: 13' + ) + expect( + wrapper.get('[data-testid="benchmark-column-workflowName"]').attributes('disabled') + ).toBe('') + expect(wrapper.get('.benchmarks__table thead').text()).toContain('Workflow') + expect(wrapper.get('[data-testid="benchmark-comparison-13-workflowName"]').text()).toContain( + 'portrait.json' + ) + expect( + wrapper.get('[data-testid="benchmark-comparison-column-workflowName"]').attributes('disabled') + ).toBe('') + expect(wrapper.get('[data-testid="benchmark-comparison-13-workflowName"]').text()).toContain( + 'portrait.json' + ) + expect(wrapper.get('[data-testid="benchmark-chart-13-workflowName"]').text()).toContain( + 'portrait.json' + ) + + await wrapper.get('input[type="text"]').setValue('landscape') + expect(wrapper.findAll('[data-testid^="benchmark-row-"]')).toHaveLength(1) + expect(wrapper.get('[data-testid="benchmark-row-10"]').text()).toContain('landscape.json') + + await wrapper.get('[data-testid="benchmark-row-10"] input[type="checkbox"]').setValue(true) + expect(wrapper.get('[data-testid="benchmark-row-10"]').attributes('style')).toContain( + '--series-color: #ff8a65' + ) + expect(wrapper.get('.benchmarks__comparison').text()).toContain('landscape.json') + + const comparisonToggle = wrapper.get('#comparison-title') + expect(comparisonToggle.attributes('aria-expanded')).toBe('true') + await comparisonToggle.trigger('click') + expect(comparisonToggle.attributes('aria-expanded')).toBe('false') + expect(wrapper.get('.benchmarks__comparison').attributes('style')).toContain('display: none') + await comparisonToggle.trigger('click') + + await wrapper.get('[data-testid="benchmark-row-10"] input[type="checkbox"]').setValue(false) + expect(wrapper.get('[data-testid="benchmark-row-10"]').attributes('style')).toBeUndefined() + await wrapper.get('input[type="text"]').setValue('') + await wrapper.get('[data-testid="benchmark-row-13"] input[type="checkbox"]').setValue(false) + await wrapper.get('[data-testid="benchmark-row-12"] input[type="checkbox"]').setValue(false) + await wrapper.get('[data-testid="benchmark-row-11"] input[type="checkbox"]').setValue(false) + expect(wrapper.get('.benchmarks__comparison').text()).toContain( + 'Select runs from the library to compare them.' + ) + }) + + it('reorders comparison columns by dragging a column title', async () => { + const wrapper = mountView() + await flushPromises() + + const columnIds = () => + wrapper + .findAll('[data-testid^="benchmark-comparison-column-title-"]') + .map((header) => header.attributes('data-testid')?.split('-').at(-1)) + + expect(columnIds()).toEqual(['13', '12', '11']) + + const dataTransfer = { + effectAllowed: 'none', + dropEffect: 'none', + setData: vi.fn(), + getData: vi.fn(() => '13') + } + await wrapper + .get('[data-testid="benchmark-comparison-column-title-13"] .benchmarks__matrix-title') + .trigger('dragstart', { dataTransfer }) + await wrapper.get('[data-testid="benchmark-comparison-column-title-11"]').trigger('dragover', { + dataTransfer + }) + expect( + wrapper + .get('[data-testid="benchmark-comparison-column-title-11"]') + .classes('benchmarks__matrix-column--drop-target') + ).toBe(true) + await wrapper.get('[data-testid="benchmark-comparison-column-title-11"]').trigger('drop', { + dataTransfer + }) + + expect(columnIds()).toEqual(['12', '13', '11']) + + await wrapper + .get('[data-testid="benchmark-comparison-column-title-11"] .benchmarks__matrix-title') + .trigger('dragstart', { dataTransfer }) + await wrapper.get('[data-testid="benchmark-comparison-column-title-12"]').trigger('drop', { + dataTransfer + }) + + expect(columnIds()).toEqual(['11', '12', '13']) + expect(wrapper.findAll('.benchmarks__chart-row strong').map((title) => title.text())).toEqual([ + 'portrait.json', + 'product.json', + 'portrait.json' + ]) + }) + + it('sorts comparison columns by duration metrics in either direction', async () => { + const wrapper = mountView() + await flushPromises() + + const columnIds = () => + wrapper + .findAll('[data-testid^="benchmark-comparison-column-title-"]') + .map((header) => header.attributes('data-testid')?.split('-').at(-1)) + const sortSelect = wrapper + .findAllComponents(BaseSelect) + .find((select) => select.props('ariaLabel') === 'Sort comparison') + + expect(sortSelect?.props('options').map((option) => option.label)).toEqual([ + 'Manual sort', + 'Fastest', + 'Average', + 'Median', + 'Slowest' + ]) + + sortSelect?.vm.$emit('update:modelValue', 'fastestJobDurationSeconds') + await wrapper.vm.$nextTick() + expect(columnIds()).toEqual(['11', '13', '12']) + + await wrapper.get('.benchmarks__sort-direction').trigger('click') + expect(columnIds()).toEqual(['12', '13', '11']) + expect(wrapper.get('.benchmarks__sort-direction').attributes('aria-label')).toBe( + 'Switch to ascending sort' + ) + }) + + it('places endpoint labels immediately outside their data point markers', async () => { + const edgeBenchmark = { + ...benchmark('14', 'qwen_image_2.1_int8_bf16.json', 45.83), + fastestJobDurationSeconds: 44.7, + averageJobDurationSeconds: 45.83, + medianJobDurationSeconds: 45.61, + slowestJobDurationSeconds: 47.24 + } + vi.mocked(window.api.listPerformanceTestBenchmarks).mockResolvedValueOnce({ + folderPath: 'C:\\results\\performance-tests', + benchmarks: [edgeBenchmark] + }) + const wrapper = mountView() + await flushPromises() + + const fastestLabel = wrapper.get('[data-testid="benchmark-chart-14-fastest-label"]') + expect(fastestLabel.classes()).toContain('benchmarks__chart-point-label--fastest') + expect(wrapper.get('[data-testid="benchmark-chart-14-slowest-label"]').classes()).toContain( + 'benchmarks__chart-point-label--slowest' + ) + expect(wrapper.get('[data-testid="benchmark-chart-14-average-label"]').classes()).toContain( + 'benchmarks__chart-point-label--center' + ) + + await wrapper.get('.benchmarks__export-results').trigger('click') + await flushPromises() + const svg = createResultsPngMock.mock.calls[0]![0] + const markerMatches = [...svg.matchAll(/]*\/>/g)] + const fastestLabelMatch = svg.match( + /44\.7 s<\/text>/ + ) + const slowestLabelMatch = svg.match( + /47\.24 s<\/text>/ + ) + const averageMarkerX = svg.match( + /]+r="5"[^>]+class="chart-average"/ + )?.[1] + expect(svg).toContain('r="5" fill="#55e0d1" class="chart-average"') + const averageLabelX = svg.match( + /]+class="chart-point-label" text-anchor="middle">45\.83 s<\/text>/ + )?.[1] + expect(Number(fastestLabelMatch?.[1])).toBe(Number(markerMatches[0]?.[1]) - 4) + expect(Number(slowestLabelMatch?.[1])).toBe(Number(markerMatches[1]?.[1]) + 4) + expect(Number(fastestLabelMatch?.[2])).toBe(Number(markerMatches[0]?.[2]) + 4) + expect(Number(slowestLabelMatch?.[2])).toBe(Number(markerMatches[1]?.[2]) + 4) + expect(averageLabelX).toBe(averageMarkerX) + }) + + it('confirms before deleting all files for a benchmark record', async () => { + const wrapper = mountView() + await flushPromises() + const dialogs = useDialogs() + + expect(wrapper.findAll('.benchmarks__delete-record')).toHaveLength(4) + await wrapper + .get('[data-testid="benchmark-row-13"] .benchmarks__delete-record') + .trigger('click') + expect(dialogs.state.open).toBe(true) + expect(dialogs.state.kind).toBe('confirm') + expect(dialogs.state.confirm.title).toBe('Delete portrait.json?') + expect(dialogs.state.confirm.message).toContain('permanently delete all files for session 13') + expect(deletePerformanceTestBenchmarkMock).not.toHaveBeenCalled() + + dialogs.confirmPrimary() + await flushPromises() + + expect(deletePerformanceTestBenchmarkMock).toHaveBeenCalledWith( + 'C:\\results\\performance-tests', + '13' + ) + expect(wrapper.find('[data-testid="benchmark-row-13"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="benchmark-comparison-column-title-13"]').exists()).toBe( + false + ) + }) + + it('shows an error when the benchmark delete bridge rejects', async () => { + deletePerformanceTestBenchmarkMock.mockRejectedValueOnce(new Error('Delete IPC unavailable')) + const wrapper = mountView() + await flushPromises() + const dialogs = useDialogs() + + await wrapper + .get('[data-testid="benchmark-row-13"] .benchmarks__delete-record') + .trigger('click') + dialogs.confirmPrimary() + await flushPromises() + + expect(dialogs.state.kind).toBe('alert') + expect(dialogs.state.alert.title).toBe('Could not delete benchmark') + expect(dialogs.state.alert.message).toBe('Delete IPC unavailable') + expect(wrapper.find('[data-testid="benchmark-row-13"]').exists()).toBe(true) + }) + + it('renames a session inline and preserves its comparison selection', async () => { + const wrapper = mountView() + await flushPromises() + + const sessionButton = wrapper.get('[data-testid="benchmark-row-13"] .benchmarks__session-name') + expect(sessionButton.text()).toBe('13') + expect(sessionButton.attributes('aria-label')).toBe('Edit session 13') + + await sessionButton.trigger('click') + const input = wrapper.get('.benchmarks__session-name-input') + expect(input.element.value).toBe('13') + expect( + wrapper.get('[data-testid="benchmark-row-13"] .benchmarks__delete-record').attributes() + ).toHaveProperty('disabled') + await input.setValue('gpu-baseline') + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(renamePerformanceTestBenchmarkMock).toHaveBeenCalledWith( + 'C:\\results\\performance-tests', + '13', + 'gpu-baseline' + ) + expect(wrapper.find('[data-testid="benchmark-row-13"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="benchmark-row-gpu-baseline"]').text()).toContain( + 'gpu-baseline' + ) + expect( + wrapper.find('[data-testid="benchmark-comparison-column-title-gpu-baseline"]').exists() + ).toBe(true) + }) + + it('opens the current results folder in the picker and loads a selected folder', async () => { + const customFolder = 'D:\\shared-benchmarks' + const api = window.api + vi.mocked(api.browseFolder).mockResolvedValue(customFolder) + vi.mocked(api.listPerformanceTestBenchmarks) + .mockReset() + .mockResolvedValueOnce({ + folderPath: 'C:\\results\\performance-tests', + benchmarks: sampleBenchmarks + }) + .mockResolvedValueOnce({ + folderPath: customFolder, + benchmarks: [sampleBenchmarks[3]!] + }) + const wrapper = mountView() + await flushPromises() + + await wrapper.get('.benchmarks__open-folder').trigger('click') + await flushPromises() + + expect(api.browseFolder).toHaveBeenCalledWith('C:\\results\\performance-tests') + expect(api.listPerformanceTestBenchmarks).toHaveBeenLastCalledWith(customFolder) + expect(wrapper.findAll('[data-testid^="benchmark-row-"]')).toHaveLength(1) + expect(wrapper.get('[data-testid="benchmark-row-10"]').text()).toContain('landscape.json') + }) + + it('refreshes the currently selected sessions folder', async () => { + const wrapper = mountView() + await flushPromises() + + await wrapper.get('[data-testid="benchmarks-refresh"]').trigger('click') + await flushPromises() + + expect(window.api.listPerformanceTestBenchmarks).toHaveBeenLastCalledWith( + 'C:\\results\\performance-tests' + ) + }) +}) diff --git a/src/renderer/src/views/BenchmarksView.vue b/src/renderer/src/views/BenchmarksView.vue new file mode 100644 index 000000000..2bada5f1d --- /dev/null +++ b/src/renderer/src/views/BenchmarksView.vue @@ -0,0 +1,1790 @@ + + + + + diff --git a/src/renderer/src/views/ChooserView.vue b/src/renderer/src/views/ChooserView.vue index 2160b5eff..f0d8f2ae5 100644 --- a/src/renderer/src/views/ChooserView.vue +++ b/src/renderer/src/views/ChooserView.vue @@ -19,7 +19,7 @@ import ComfyWordmark from '../components/icons/ComfyWordmark.vue' import ChooserFamilyGrid from './chooser/ChooserFamilyGrid.vue' import DevPlatformAccountChip from './devplatform/DevPlatformAccountChip.vue' import DevPlatformWorkspaceSelector from './devplatform/DevPlatformWorkspaceSelector.vue' -import { resolvePickerTab } from '../lib/pickerTabs' +import { openInstallManager } from '../lib/openInstallManager' import type { CloudUserTier, Installation, ShowProgressOpts } from '../types/ipc' import { PERSONAL_WORKSPACE_ID } from '../../../shared/workspaces' @@ -148,29 +148,6 @@ const clusterRows = computed(() => Math.ceil((1 + scopedInstallCount.value) / TI // picker popup) - the legacy `useOverlay`-driven `ManageInstallModal` // route is retired. -function openManage( - installation: Installation, - opts: { initialTab?: string; autoAction?: string | null } = {} -): void { - // Every Manage entry - bare "Manage..." and the specialised kebab - // items (Update / Migrate / Restore Snapshot / Delete) - routes to - // the instance-picker popup. Bare goes to compact (default identity - // card + CTAs); specialised paths open the picker directly in - // expanded mode on the relevant tab with `autoAction` so the action - // fires on mount of `ComfyUISettingsContent`. - const hasSpecialisedOpts = - opts.initialTab !== undefined || (opts.autoAction !== undefined && opts.autoAction !== null) - if (!hasSpecialisedOpts) { - window.api.openInstancePicker({ installationId: installation.id }) - return - } - window.api.openInstancePicker({ - installationId: installation.id, - initialTab: resolvePickerTab(opts.initialTab, 'status'), - autoAction: opts.autoAction ?? null - }) -} - function canPromoteToWorkspace(inst: Installation): boolean { return ( authStore.isSignedIn && @@ -189,10 +166,12 @@ const { handleCtxMenuSelect, closeMenu, triggerAction, + viewError, + viewDanger, isStoppedActionGated, isPromotingToWorkspace } = useInstallContextMenu({ - onManage: (inst, opts) => openManage(inst, opts ?? {}), + onManage: openInstallManager, // Fast-path for Delete: forwards to PanelApp so the same ProgressModal // pipeline used by every other long op fires here too, without the // brief ManageInstallModal flash that the autoAction route produced. @@ -220,41 +199,6 @@ async function pickInstall(inst: Installation): Promise { emit('pick', inst) } -/** Surface a failed install's error so it's readable from the dashboard. - * Covers both op failures (which carry a `message`, e.g. a migrate that - * silently did nothing but turn the tile red) and crashes (exit code / - * signal + captured stderr). */ -function viewError(inst: Installation): void { - const err = sessionStore.errorInstances.get(inst.id) - if (!err) return - let message = err.message - if (!message) { - if (err.signal && err.exitCode != null) { - message = t('comfyLifecycle.crashedDescWithCodeAndSignal', { - code: err.exitCode, - signal: err.signal - }) - } else if (err.signal) { - message = t('comfyLifecycle.crashedDescWithSignal', { signal: err.signal }) - } else if (err.exitCode != null) { - message = t('comfyLifecycle.crashedDescWithCode', { code: err.exitCode }) - } else { - message = t('comfyLifecycle.crashedDesc') - } - } - if (err.lastStderr) message = `${message}\n\n${err.lastStderr}` - void modal.alert({ title: t('chooser.errorTitle'), message }) -} - -/** Surface a backend-flagged danger state (failed install, interrupted delete, - * missing install folder) from its dashboard pill. The label is the short - * pill text; `detail` carries the full explanation built in the main process. */ -function viewDanger(inst: Installation): void { - const tag = inst.statusTag - if (!tag || tag.style !== 'danger') return - void modal.alert({ title: tag.label, message: tag.detail || tag.label }) -} - const cloudGate = useCloudGate({ immediate: false }) const cloudFreeRunsEnabled = ref(false) @@ -558,11 +502,9 @@ const gridHandlers = { .chooser-shelves { grid-row: 4; width: 100%; - /* Content box must hold exactly 4 tracks (4 x 280 + 3 x 16 = 1168px), so the - * side padding sits OUTSIDE the cap - inside it, `auto-fit` drops to 3 - * columns on a wide viewport. */ - --shelf-pad-x: 4px; - max-width: calc(1168px + 2 * var(--shelf-pad-x)); + /* Content box holds exactly 4 tracks (4 x 280 + 3 x 16 = 1168px). */ + max-width: 1168px; + justify-self: start; /* Reserve the unfiltered row height so the cluster doesn't jump while typing * in search. Tile is 178px tall (280px at the golden-ratio aspect). */ --tile-h: 178px; @@ -579,7 +521,7 @@ const gridHandlers = { * glide under it rather than clip abruptly. Fluid on height (`--chooser-fade`) * so short viewports reclaim the band for an extra tile row. */ --chooser-fade: clamp(12px, 2.5vh, 24px); - padding: var(--chooser-fade) var(--shelf-pad-x); + padding-block: var(--chooser-fade); /* Size container so each shelf below can snap its width to a whole number * of tile columns. */ container-type: inline-size; @@ -603,16 +545,14 @@ const gridHandlers = { flex-direction: column; /* The grid's own row gap, so two stacked grids read as continuous rows. */ gap: 16px; - /* Snap each shelf to a whole number of 280px tracks (16px gaps) and center - * the snapped block. Without this, a viewport that fits fewer than 4 - * columns leaves the start-aligned grids pinned left under the centered - * wordmark/search with a dead right gutter. Snapping makes start-aligned - * and centered rows coincide, and shelf header rules end at the last - * column. Thresholds are `cols * 280 + (cols - 1) * 16` against the - * shelves' content box (the container defined above). */ + /* Snap each shelf to a whole number of 280px tracks (16px gaps) while + * keeping partial-width shelves aligned with the left edge. Thresholds are + * `cols * 280 + (cols - 1) * 16` against the shelves' content box (the + * container defined above). */ width: 100%; max-width: 280px; - margin-inline: auto; + align-self: flex-start; + margin: 0; } @container (width >= 576px) { .chooser-shelf { diff --git a/src/renderer/src/views/InstallWizardModal.vue b/src/renderer/src/views/InstallWizardModal.vue index 57cd782ca..5afe84ad9 100644 --- a/src/renderer/src/views/InstallWizardModal.vue +++ b/src/renderer/src/views/InstallWizardModal.vue @@ -269,7 +269,7 @@ function installHandoffProps(): Record { const variantId = selections.value.variant?.data?.variantId as string | undefined return { entrypoint: entrypoint.value, - source_id: managedBuildMode.value ? 'comfybuilder' : (currentSource.value?.id ?? null), + source_id: managedBuildMode.value ? 'platform' : (currentSource.value?.id ?? null), variant: variantId ? toVariantBucket(variantId) : null, express: false } diff --git a/src/renderer/src/views/PerformanceTestView.vue b/src/renderer/src/views/PerformanceTestView.vue new file mode 100644 index 000000000..27b360906 --- /dev/null +++ b/src/renderer/src/views/PerformanceTestView.vue @@ -0,0 +1,1334 @@ + + + + + diff --git a/src/renderer/src/views/chooser/ChooserInstallTile.vue b/src/renderer/src/views/chooser/ChooserInstallTile.vue index eab8a125d..63a2cc642 100644 --- a/src/renderer/src/views/chooser/ChooserInstallTile.vue +++ b/src/renderer/src/views/chooser/ChooserInstallTile.vue @@ -22,13 +22,20 @@ interface Props { installation: Installation showFreeRunsPill?: boolean showWhyCloud?: boolean + /** Override runtime lifecycle presentation for surfaces that own separate sessions. */ + lifecycleStatus?: 'idle' | 'launching' | 'running' | 'stopping' | 'error' /** True when REQUIRES_STOPPED actions (update / migrate / restore / delete) are gated. */ isStoppedActionGated: boolean /** True while Desktop captures this instance and creates its workspace draft. */ isPromotingToWorkspace?: boolean + /** Hide the card's context/kebab menu while keeping inline action pills enabled. */ + showMenu?: boolean } -const props = defineProps() +const props = withDefaults(defineProps(), { + lifecycleStatus: undefined, + showMenu: true +}) const emit = defineEmits<{ pick: [installation: Installation] @@ -45,9 +52,21 @@ const sessionStore = useSessionStore() const inst = computed(() => props.installation) -const isRunning = computed(() => sessionStore.isRunning(inst.value.id)) -const isLaunching = computed(() => sessionStore.isLaunching(inst.value.id)) -const isStopping = computed(() => sessionStore.isStopping(inst.value.id)) +const isRunning = computed(() => + props.lifecycleStatus == null + ? sessionStore.isRunning(inst.value.id) + : props.lifecycleStatus === 'running' +) +const isLaunching = computed(() => + props.lifecycleStatus == null + ? sessionStore.isLaunching(inst.value.id) + : props.lifecycleStatus === 'launching' +) +const isStopping = computed(() => + props.lifecycleStatus == null + ? sessionStore.isStopping(inst.value.id) + : props.lifecycleStatus === 'stopping' +) /* A managed update flips the record to status 'updating'; a standalone * update never touches the record and is only visible through main's * operation broadcast. Key on both so the tile reports "Updating" the same @@ -57,7 +76,11 @@ const isUpdating = computed(() => { const op = sessionStore.operationInstances.get(inst.value.id) return op != null && progressOpKindForActionId(op.actionId) === 'update' }) -const hasError = computed(() => sessionStore.errorInstances.has(inst.value.id)) +const hasError = computed(() => + props.lifecycleStatus == null + ? sessionStore.errorInstances.has(inst.value.id) + : props.lifecycleStatus === 'error' +) /* Backend-flagged problem states (failed install, interrupted delete, missing * install folder) carry a `danger` statusTag. Surface it as a static red pill — @@ -176,7 +199,7 @@ function handleClick(): void { } function handleContextMenu(event: MouseEvent): void { - if (isUpdating.value) return + if (!props.showMenu || isUpdating.value) return emit('open-card-menu', event, inst.value) } @@ -257,7 +280,7 @@ function triggerInstallAction(action: 'update' | 'migrate'): void { {{ dangerTag.label }}