From adf1a1a8a5f6b3000f691d470f5723ab8f46f9e2 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 8 Sep 2026 15:35:48 -0700 Subject: [PATCH 1/2] fix: block standalone installs without a compatible runtime --- locales/en.json | 2 + locales/zh.json | 2 + .../ipc/registerInstallationHandlers.test.ts | 112 ++++++++++++++ .../lib/ipc/registerInstallationHandlers.ts | 5 + .../sessionActions/copy.integration.test.ts | 2 +- src/main/sources/standalone/index.test.ts | 40 +++++ src/main/sources/standalone/index.ts | 13 +- .../sources/standalone/runtimeValidation.ts | 30 ++++ .../src/views/InstallWizardModal.test.ts | 141 +++++++++++++++++- src/renderer/src/views/InstallWizardModal.vue | 41 ++++- 10 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 src/main/lib/ipc/registerInstallationHandlers.test.ts create mode 100644 src/main/sources/standalone/runtimeValidation.ts diff --git a/locales/en.json b/locales/en.json index f316e529b..18519fc51 100644 --- a/locales/en.json +++ b/locales/en.json @@ -919,6 +919,8 @@ "adoptStepRegister": "Register installation" }, "standalone": { + "runtimeUnavailable": "No compatible standalone runtime is available. Try again later or use a remote connection.", + "invalidRuntime": "Select a release and a compatible runtime before creating an installation.", "_note": "standalone.label and portable.label are product names — keep in English across all translations.", "label": "Standalone", "desc": "Pre-built environment with managed Python and dependencies.", diff --git a/locales/zh.json b/locales/zh.json index 6bf5d4984..0f9e2141f 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -919,6 +919,8 @@ "adoptStepRegister": "注册安装" }, "standalone": { + "runtimeUnavailable": "没有可用的兼容独立版运行环境。请稍后重试,或使用远程连接。", + "invalidRuntime": "创建实例前,请选择发布版本和兼容的运行环境。", "_note": "standalone.label 和 portable.label 是产品名称——所有翻译中请保持英文。", "label": "独立版", "desc": "预构建环境,包含托管 Python 和依赖。", diff --git a/src/main/lib/ipc/registerInstallationHandlers.test.ts b/src/main/lib/ipc/registerInstallationHandlers.test.ts new file mode 100644 index 000000000..e5b4e162d --- /dev/null +++ b/src/main/lib/ipc/registerInstallationHandlers.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getPath: () => '/tmp', getVersion: () => '0.0.0-test' }, + ipcMain: { handle: vi.fn(), on: vi.fn() }, + dialog: {}, + shell: {}, + BrowserWindow: { getAllWindows: () => [] }, + nativeTheme: { on: vi.fn(), shouldUseDarkColors: false } +})) +vi.mock('../../installations', () => ({ add: vi.fn() })) +vi.mock('./installIdentity', () => ({ allocateInstallIdentity: vi.fn() })) + +import { ipcMain } from 'electron' +import * as installations from '../../installations' +import { allocateInstallIdentity } from './installIdentity' +import { registerInstallationHandlers } from './registerInstallationHandlers' + +describe('add-installation standalone runtime validation', () => { + const runtime = { + sourceId: 'standalone', + version: '0.18.3', + releaseTag: 'v0.18.3-env1', + variant: 'linux-cpu', + pythonVersion: '3.13.12', + downloadUrl: 'https://example.com/runtime.tar.gz', + downloadFiles: [ + { url: 'https://example.com/runtime.tar.gz', filename: 'runtime.tar.gz', size: 1000 } + ] + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(allocateInstallIdentity).mockResolvedValue({ + ok: true, + name: 'ComfyUI', + installPath: '/tmp/install' + }) + vi.mocked(installations.add).mockResolvedValue({ + id: 'new' + } as installations.InstallationRecord) + registerInstallationHandlers() + }) + + async function add(data: Record) { + const handler = vi + .mocked(ipcMain.handle) + .mock.calls.find(([channel]) => channel === 'add-installation')![1] + return handler({} as Electron.IpcMainInvokeEvent, data) + } + + it.each([ + { version: undefined }, + { releaseTag: '' }, + { variant: ' ' }, + { pythonVersion: undefined }, + { downloadFiles: [], downloadUrl: '' }, + { downloadFiles: [{}] }, + { downloadFiles: [{ url: '', filename: 'runtime.tar.gz' }] }, + { downloadFiles: [{ url: runtime.downloadUrl, filename: '' }] }, + { downloadFiles: 'invalid' } + ])( + 'rejects malformed runtime data before any allocation or record creation: %j', + async (data) => { + await expect(add({ ...runtime, ...data })).resolves.toEqual({ + ok: false, + message: 'standalone.invalidRuntime' + }) + expect(allocateInstallIdentity).not.toHaveBeenCalled() + expect(installations.add).not.toHaveBeenCalled() + } + ) + + it('rejects a direct request without any runtime selections', async () => { + await expect(add({ sourceId: 'standalone' })).resolves.toMatchObject({ ok: false }) + expect(allocateInstallIdentity).not.toHaveBeenCalled() + expect(installations.add).not.toHaveBeenCalled() + }) + + it.each([ + {}, + { downloadFiles: undefined }, + { downloadFiles: [], updateChannel: 'latest' }, + { downloadUrl: undefined } + ])( + 'persists valid runtimes, including legacy URL downloads and absent optional fields: %j', + async (data) => { + await expect(add({ ...runtime, ...data })).resolves.toMatchObject({ + ok: true, + entry: { id: 'new' } + }) + expect(installations.add).toHaveBeenCalledExactlyOnceWith({ + ...runtime, + ...data, + name: 'ComfyUI', + installPath: '/tmp/install', + status: 'installing', + seen: false + }) + } + ) + + it.each(['remote', 'cloud', 'git', 'portable', 'comfybuilder'])( + 'preserves the %s entry point', + async (sourceId) => { + await expect(add({ sourceId, status: 'installed' })).resolves.toMatchObject({ ok: true }) + expect(installations.add).toHaveBeenCalledWith( + expect.objectContaining({ sourceId, status: 'installed' }) + ) + } + ) +}) diff --git a/src/main/lib/ipc/registerInstallationHandlers.ts b/src/main/lib/ipc/registerInstallationHandlers.ts index 04c11b78a..652d2d41b 100644 --- a/src/main/lib/ipc/registerInstallationHandlers.ts +++ b/src/main/lib/ipc/registerInstallationHandlers.ts @@ -52,6 +52,7 @@ import { abortModelStaging } from '../../sources/comfybuilder/modelStagingTask' import { recordIpcInvocation } from '../e2eOverrides' import { DEFAULT_INSTALL_NAME } from '../../../shared/defaultInstallName' import { isInstallationVisibleToRenderer } from './installationVisibility' +import { getStandaloneRuntimeError } from '../../sources/standalone/runtimeValidation' /** Fire-and-forget: refresh the shared ComfyUI release cache for the * channels these installs use, then re-broadcast `installations-changed` @@ -223,6 +224,10 @@ export function registerInstallationHandlers(): void { }) ipcMain.handle('add-installation', async (_event, data: Record) => { + if (data.sourceId === 'standalone') { + const runtimeError = getStandaloneRuntimeError(data) + if (runtimeError) return { ok: false, message: runtimeError } + } const identity = await allocateInstallIdentity( (data.name as string) || DEFAULT_INSTALL_NAME, (data.installPath as string | undefined) || undefined diff --git a/src/main/lib/ipc/sessionActions/copy.integration.test.ts b/src/main/lib/ipc/sessionActions/copy.integration.test.ts index dafb036f1..075e2d3d5 100644 --- a/src/main/lib/ipc/sessionActions/copy.integration.test.ts +++ b/src/main/lib/ipc/sessionActions/copy.integration.test.ts @@ -273,7 +273,7 @@ describe('handleReleaseUpdate (release-update success path)', () => { variantId: 'cuda', manifest: { id: 'cuda', comfyui_ref: 'v0.3.0', python_version: '3.12.4' }, downloadFiles: [], - downloadUrl: '', + downloadUrl: 'https://example.com/x.zip', r2Release: { tag: 'v1.0.0', comfyui_version: '0.3.0', diff --git a/src/main/sources/standalone/index.test.ts b/src/main/sources/standalone/index.test.ts index 41b929431..c9af79617 100644 --- a/src/main/sources/standalone/index.test.ts +++ b/src/main/sources/standalone/index.test.ts @@ -101,6 +101,46 @@ describe('standalone.buildInstallation', () => { } as unknown as Record }) + it.each(['release', 'variant'] as const)('rejects a missing %s selection', (field) => { + const selections = { release: makeRelease('latest'), variant: makeVariant(VENDOR_ID) } + expect(() => standalone.buildInstallation({ ...selections, [field]: undefined })).toThrow( + 'standalone.invalidRuntime' + ) + }) + + it('rejects an empty runtime catalog instead of building an unknown installation', () => { + expect(() => + standalone.buildInstallation({ bundledTemplate: { value: NO_TEMPLATE_VALUE, label: 'None' } }) + ).toThrow('standalone.invalidRuntime') + }) + + it.each([ + { variantId: '' }, + { downloadUrl: '', downloadFiles: [] }, + { manifest: { comfyui_ref: '0.18.3', python_version: '' } } + ])('rejects incomplete variant data: %j', (data) => { + const variant = makeVariant(VENDOR_ID) + variant.data = { ...variant.data, ...data } + expect(() => standalone.buildInstallation({ release: makeRelease('latest'), variant })).toThrow( + 'standalone.invalidRuntime' + ) + }) + + it('allows latest without a ComfyUI version or starter template', () => { + expect( + standalone.buildInstallation({ + release: makeRelease('latest'), + variant: makeVariant(VENDOR_ID) + }) + ).toMatchObject({ + updateChannel: 'latest', + variant: VENDOR_ID, + releaseTag: 'v0.18.2-env1', + pythonVersion: '3.13.12', + downloadFiles: [expect.objectContaining({ url: 'https://example.com/download.tar.gz' })] + }) + }) + it('Stable: sets autoUpdateComfyUI + updateChannel="stable" so post-install checks out the latest stable tag', () => { const result = standalone.buildInstallation({ release: makeRelease('stable', 'v0.18.2-env1'), diff --git a/src/main/sources/standalone/index.ts b/src/main/sources/standalone/index.ts index fcf7df720..6c2481cd1 100644 --- a/src/main/sources/standalone/index.ts +++ b/src/main/sources/standalone/index.ts @@ -22,6 +22,7 @@ import { install, postInstall, probeInstallation } from './install' import { NO_TEMPLATE_VALUE, isPersistableTemplateId } from './curatedTemplates' import { loadTemplateCatalog } from './templateCatalog' import { resolveTemplateModels } from './templateModels' +import { getStandaloneRuntimeError } from './runtimeValidation' import * as installations from '../../installations' import { getListPreview, getStatusTag, getDetailSections, R2_BASE_URL } from './updateSections' @@ -169,6 +170,13 @@ export const standalone: SourcePlugin = { getDetailSections, buildInstallation(selections: Record): Record { + if ( + ![selections.release, selections.variant].every( + (option) => typeof option?.value === 'string' && option.value.trim().length > 0 + ) + ) { + throw new Error(t('standalone.invalidRuntime')) + } const vd = selections.variant?.data as (VariantData & { r2Release?: R2Variant }) | undefined const manifest = vd?.manifest const r2Release = vd?.r2Release @@ -210,7 +218,7 @@ export const standalone: SourcePlugin = { typeof selections.bundledTemplate?.data?.sizeBytes === 'number' ? (selections.bundledTemplate.data.sizeBytes as number) : 0 - return { + const installation = { version: r2Release?.comfyui_version || manifest?.comfyui_ref || releaseTag, releaseTag, variant: variantId, @@ -244,6 +252,9 @@ export const standalone: SourcePlugin = { } : {}) } + const runtimeError = getStandaloneRuntimeError(installation) + if (runtimeError) throw new Error(runtimeError) + return installation }, getLaunchCommand(installation: InstallationRecord): LaunchCommand | null { diff --git a/src/main/sources/standalone/runtimeValidation.ts b/src/main/sources/standalone/runtimeValidation.ts new file mode 100644 index 000000000..f15bab5c6 --- /dev/null +++ b/src/main/sources/standalone/runtimeValidation.ts @@ -0,0 +1,30 @@ +import { t } from '../../lib/i18n' + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +/** Validate fresh standalone installs before persisting or allocating a directory. + * Tracked/adopted installs use separate entry points and need no download. */ +export function getStandaloneRuntimeError(data: Record): string | null { + const files = data.downloadFiles + // Match install(): a non-empty file list takes precedence over the legacy URL. + const hasDownload = + Array.isArray(files) && files.length > 0 + ? files.every((file: unknown) => { + if (!file || typeof file !== 'object') return false + return ( + 'url' in file && + isNonEmptyString(file.url) && + 'filename' in file && + isNonEmptyString(file.filename) + ) + }) + : (files === undefined || (Array.isArray(files) && files.length === 0)) && + isNonEmptyString(data.downloadUrl) + return ['version', 'releaseTag', 'variant', 'pythonVersion'].every((key) => + isNonEmptyString(data[key]) + ) && hasDownload + ? null + : t('standalone.invalidRuntime') +} diff --git a/src/renderer/src/views/InstallWizardModal.test.ts b/src/renderer/src/views/InstallWizardModal.test.ts index c859cb6bc..a068b315c 100644 --- a/src/renderer/src/views/InstallWizardModal.test.ts +++ b/src/renderer/src/views/InstallWizardModal.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { createI18n } from 'vue-i18n' import { createPinia, setActivePinia } from 'pinia' @@ -8,6 +8,7 @@ import InstallWizardModal from './InstallWizardModal.vue' import BaseSelect from '../components/ui/BaseSelect.vue' import BrandVariantList from '../components/BrandVariantList.vue' import PathDiskInfo from '../components/PathDiskInfo.vue' +import type { Source, FieldOption } from '../types/ipc' function makeI18n() { return createI18n({ legacy: false, locale: 'en', messages: { en } }) @@ -58,6 +59,144 @@ beforeEach(() => { } as unknown as typeof window.api }) +describe('InstallWizardModal standalone runtime availability', () => { + const standalone: Source = { + id: 'standalone', + label: 'Standalone', + category: 'local', + fields: [ + { id: 'release', label: 'Release', type: 'select' }, + { id: 'comfyVersion', label: 'ComfyUI version', type: 'select' }, + { id: 'variant', label: 'Variant', type: 'select', renderAs: 'cards' }, + { id: 'bundledTemplate', label: 'Template', type: 'select', renderAs: 'cards' } + ] + } + const release = { value: 'latest', label: 'Latest on GitHub' } + const variant = { value: 'cpu', label: 'CPU', data: { variantId: 'cpu' } } + let wrapper: ReturnType + + beforeEach(() => { + vi.mocked(window.api.getSources).mockResolvedValue([standalone]) + vi.mocked(window.api.getSetting).mockResolvedValue(true) + }) + afterEach(() => wrapper?.unmount()) + + async function openWithOptions(options: Record): Promise { + vi.mocked(window.api.getFieldOptions).mockImplementation( + async (_source, field) => options[field] ?? [] + ) + wrapper = mountModal() + await (wrapper.vm as unknown as { open: () => Promise }).open() + await flushPromises() + } + + it.each(['release', 'variant'])('blocks an empty required %s catalog', async (field) => { + await openWithOptions({ + release: field === 'release' ? [] : [release], + bundledTemplate: [{ value: 'none', label: 'None' }] + }) + + expect(wrapper.get('.wizard-error').text()).toBe(en.standalone.runtimeUnavailable) + const button = wrapper.get('button.config-continue') + expect(button.element.disabled).toBe(true) + await button.trigger('click') + await flushPromises() + expect(window.api.buildInstallation).not.toHaveBeenCalled() + expect(window.api.addInstallation).not.toHaveBeenCalled() + expect(window.api.getFieldOptions).not.toHaveBeenCalledWith( + 'standalone', + 'bundledTemplate', + expect.anything(), + undefined + ) + }) + + it.each([{ templates: [] }, { templates: [{ value: 'none', label: 'None' }] }])( + 'allows latest with an empty ComfyUI version and optional templates $templates', + async ({ templates }) => { + await openWithOptions({ release: [release], variant: [variant], bundledTemplate: templates }) + + expect(wrapper.find('.wizard-error').exists()).toBe(false) + const button = wrapper.get('button.config-continue') + expect(button.element.disabled).toBe(false) + await button.trigger('click') + await flushPromises() + expect(window.api.buildInstallation).toHaveBeenCalledExactlyOnceWith('standalone', { + release, + variant, + ...(templates.length ? { bundledTemplate: templates[0] } : {}) + }) + expect(window.api.addInstallation).toHaveBeenCalledOnce() + } + ) + + it('clears the previous runtime when a channel has no variants and recovers on a valid channel', async () => { + const stable = { value: 'stable', label: 'Stable' } + await openWithOptions({ release: [stable, release], variant: [variant] }) + vi.mocked(window.api.getFieldOptions).mockImplementation(async (_source, field, selections) => + field === 'variant' && selections.release?.value === 'stable' ? [variant] : [] + ) + const releaseSelect = wrapper.findAllComponents(BaseSelect)[0]! + releaseSelect.vm.$emit('update:modelValue', 'latest') + await flushPromises() + + expect(wrapper.get('.wizard-error').text()).toBe(en.standalone.runtimeUnavailable) + expect(wrapper.get('.config-continue').element.disabled).toBe(true) + expect(wrapper.findComponent(BrandVariantList).exists()).toBe(false) + + releaseSelect.vm.$emit('update:modelValue', 'stable') + await flushPromises() + expect(wrapper.find('.wizard-error').exists()).toBe(false) + expect(wrapper.get('.config-continue').element.disabled).toBe(false) + await wrapper.get('.config-continue').trigger('click') + await flushPromises() + expect(window.api.buildInstallation).toHaveBeenCalledExactlyOnceWith('standalone', { + release: stable, + variant + }) + }) + + it.each(['remote', 'cloud'])('allows switching from unavailable standalone to %s', async (id) => { + vi.mocked(window.api.getSources).mockResolvedValue([ + standalone, + { + id, + label: id, + category: 'remote', + skipInstall: true, + fields: [{ id: 'url', label: 'URL', type: 'text', defaultValue: 'http://localhost:8188' }] + } + ]) + vi.mocked(window.api.buildInstallation).mockResolvedValue({ sourceId: id }) + await openWithOptions({}) + await wrapper + .findAll('button[role="radio"]') + .find((button) => button.text() === id)! + .trigger('click') + await flushPromises() + + expect(wrapper.find('.wizard-error').exists()).toBe(false) + expect(wrapper.get('.config-continue').element.disabled).toBe(false) + await wrapper.get('.config-continue').trigger('click') + await flushPromises() + expect(window.api.addInstallation).toHaveBeenCalledWith( + expect.objectContaining({ sourceId: id, status: 'installed' }) + ) + }) + + it('shows main-process validation errors without adding an installation', async () => { + vi.mocked(window.api.buildInstallation).mockRejectedValue( + new Error(en.standalone.invalidRuntime) + ) + await openWithOptions({ release: [release], variant: [variant] }) + await wrapper.get('.config-continue').trigger('click') + await flushPromises() + + expect(wrapper.get('.wizard-error').text()).toBe(en.standalone.invalidRuntime) + expect(window.api.addInstallation).not.toHaveBeenCalled() + }) +}) + describe('InstallWizardModal heading', () => { it('uses the new-instance title and local-install subtitle', async () => { const wrapper = mountModal() diff --git a/src/renderer/src/views/InstallWizardModal.vue b/src/renderer/src/views/InstallWizardModal.vue index ec1526c0c..98e3c74f4 100644 --- a/src/renderer/src/views/InstallWizardModal.vue +++ b/src/renderer/src/views/InstallWizardModal.vue @@ -344,6 +344,7 @@ function selectTemplate(option: FieldOption): void { * when the picker is gated off (non-standalone source, no template options, * disk too small, or the `skipTemplatePickerStep` opt-out). */ async function handleConfigureContinue(): Promise { + if (!canContinue.value) return if (shouldShowPickerStep.value) { // No template is pre-selected - the "None" sentinel stays put until the // user actively picks a card, so nobody installs a starter workflow (and @@ -444,6 +445,21 @@ const urlFieldError = computed(() => { return isValidConnectionUrl(value) ? '' : t('newInstall.urlInvalid') }) +function isRequiredRuntimeField(field: SourceField): boolean { + return ( + currentSource.value?.id === 'standalone' && (field.id === 'release' || field.id === 'variant') + ) +} + +const runtimeUnavailable = computed(() => + currentSource.value?.fields.some( + (field) => + isRequiredRuntimeField(field) && + !fieldLoading.value.get(field.id) && + fieldOptions.value.get(field.id)?.length === 0 + ) +) + // Continue gate. `skipInstall` sources (Remote Connection) have no install path, so the path-issue guard is skipped for them. const canContinue = computed(() => { if (managedBuildMode.value) { @@ -456,6 +472,13 @@ const canContinue = computed(() => { ) } if (!currentSource.value) return false + if ( + currentSource.value.fields.some( + (field) => isRequiredRuntimeField(field) && !selections.value[field.id]?.value + ) + ) { + return false + } if (nameError.value || urlFieldError.value) return false if (currentSource.value.skipInstall) return !saveDisabled.value return !saveDisabled.value && pathIssues.value.length === 0 @@ -824,10 +847,10 @@ async function loadFieldOptions(fieldIndex: number): Promise { // [] when not applicable. Drop any stale selection so a value from a // prior channel toggle doesn't leak into `buildInstallation`. delete selections.value[field.id] + if (isRequiredRuntimeField(field)) return } - // Load next select field. An empty-options field still hands off downstream - // so a conditional field can't strand the chain (would leave Continue disabled). + // Empty optional fields still hand off downstream (e.g. comfyVersion on latest). const nextSelect = source.fields.findIndex((f, i) => i > fieldIndex && f.type !== 'text') if (nextSelect >= 0) { await loadFieldOptions(nextSelect) @@ -1021,6 +1044,7 @@ async function handleWorkspaceBuildSave(): Promise { } async function handleSave(): Promise { + if (!canContinue.value) return if (managedBuildMode.value) { await handleWorkspaceBuildSave() return @@ -1051,7 +1075,14 @@ async function handleSave(): Promise { // the template id, so "Skip & Install" (template = None) means no download. // The renderer doesn't sync a separate consent field. - const instData = await window.api.buildInstallation(source.id, rawSelections()) + let instData: Record + try { + instData = await window.api.buildInstallation(source.id, rawSelections()) + } catch (error) { + sourceError.value = error instanceof Error ? error.message : String(error) + step.value = 'configure' + return + } const baseName = instName.value.trim() || DEFAULT_INSTALL_NAME const name = await window.api.getUniqueName(baseName) @@ -1338,7 +1369,9 @@ defineExpose({ open }) -
{{ sourceError }}
+
Date: Tue, 8 Sep 2026 16:32:45 -0700 Subject: [PATCH 2/2] fix: handle standalone validation at installation boundaries --- src/main/lib/buildInstallation.ts | 24 ++ .../lib/installationBuild.integration.test.ts | 215 ++++++++++++++++++ src/main/lib/ipc/registerAppHandlers.ts | 15 +- src/main/lib/ipc/registerSnapshotHandlers.ts | 10 +- src/main/lib/ipc/sessionActions/copy.ts | 5 +- src/main/lib/standaloneMigration.ts | 31 +-- .../src/panel/useFirstUseChain.test.ts | 12 +- src/renderer/src/panel/useFirstUseChain.ts | 10 +- .../src/views/InstallWizardModal.test.ts | 11 +- src/renderer/src/views/InstallWizardModal.vue | 8 +- .../src/views/QuickInstallModal.test.ts | 77 +++++++ src/renderer/src/views/QuickInstallModal.vue | 9 +- src/types/ipc.ts | 6 +- 13 files changed, 393 insertions(+), 40 deletions(-) create mode 100644 src/main/lib/buildInstallation.ts create mode 100644 src/main/lib/installationBuild.integration.test.ts create mode 100644 src/renderer/src/views/QuickInstallModal.test.ts diff --git a/src/main/lib/buildInstallation.ts b/src/main/lib/buildInstallation.ts new file mode 100644 index 000000000..a5ec2afae --- /dev/null +++ b/src/main/lib/buildInstallation.ts @@ -0,0 +1,24 @@ +import type { BuildInstallationResult } from '../../types/ipc' +import type { FieldOption, SourcePlugin } from '../types/sources' +import { t } from './i18n' + +/** Keep source validation failures inside each caller's result/error convention, + * including IPC, where throwing would wrap the localized message in an Electron error. */ +export function tryBuildInstallation( + source: SourcePlugin | undefined, + selections: Record +): BuildInstallationResult { + if (!source) return { ok: false, message: t('errors.unknownSource') } + try { + return { + ok: true, + data: { + sourceId: source.id, + sourceLabel: source.label, + ...source.buildInstallation(selections) + } + } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/lib/installationBuild.integration.test.ts b/src/main/lib/installationBuild.integration.test.ts new file mode 100644 index 000000000..3d17eb40a --- /dev/null +++ b/src/main/lib/installationBuild.integration.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import type * as GpuModule from './gpu' +import type * as I18nModule from './i18n' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getPath: () => os.tmpdir(), getVersion: () => '0.0.0-test' }, + ipcMain: { handle: vi.fn(), on: vi.fn() }, + dialog: {}, + shell: {}, + BrowserWindow: { getAllWindows: () => [] }, + nativeTheme: { on: vi.fn(), shouldUseDarkColors: false } +})) +vi.mock('../installations', () => ({ add: vi.fn() })) +vi.mock('./gpu', async (importOriginal) => ({ + ...(await importOriginal()), + detectGPU: vi.fn().mockResolvedValue(null) +})) +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal()), + t: (await import('./localeTestHelper')).lookupEnMessage +})) +vi.mock('./telemetry', () => ({ + trackedStep: async (_name: string, _ctx: unknown, fn: () => Promise) => fn() +})) + +import { ipcMain } from 'electron' +import * as installations from '../installations' +import { standalone, buildPinnedVariant } from '../sources/standalone' +import type { FieldOption } from '../types/sources' +import { lookupEnMessage } from './localeTestHelper' +import { registerAppHandlers } from './ipc/registerAppHandlers' +import { registerSnapshotHandlers } from './ipc/registerSnapshotHandlers' +import { handleReleaseUpdate } from './ipc/sessionActions/copy' +import { + migrateToStandaloneFromSnapshot, + type StandaloneTargetSelection +} from './standaloneMigration' + +describe('standalone build validation at caller boundaries', () => { + let root: string + let snapshotFile: string + let release: FieldOption + let variant: FieldOption + const failure = { ok: false, message: lookupEnMessage('standalone.invalidRuntime') } + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'installation-build-')) + snapshotFile = path.join(root, 'snapshot.json') + fs.writeFileSync( + snapshotFile, + JSON.stringify({ + type: 'comfyui-desktop-2-snapshot', + version: 1, + installationName: 'Original', + snapshots: [ + { + version: 1, + createdAt: '2026-09-08T00:00:00Z', + trigger: 'manual', + comfyui: { ref: 'v0.18.3', commit: null, releaseTag: 'bundle', variant: 'linux-cpu' }, + customNodes: [], + pipPackages: {} + } + ] + }) + ) + // An empty Python version survives catalog parsing and reaches hydrated options. + release = { + value: 'stable', + label: 'Stable', + data: { + vendorReleases: { + 'linux-cpu': [ + { + tag: 'bundle', + file: 'runtime.tar.gz', + size: 1000, + comfyui_version: '0.18.3', + comfyui_commit: 'abc123', + build: 1, + date: '2026-09-08T00:00:00Z', + python_version: '', + torch_version: '2.7.0' + } + ] + } + } + } + variant = buildPinnedVariant(release, 'linux-cpu', 'bundle')! + vi.spyOn(standalone, 'getFieldOptions').mockImplementation(async (field) => + field === 'release' ? [release] : field === 'variant' ? [variant] : [] + ) + registerAppHandlers() + registerSnapshotHandlers() + }) + + afterEach(() => { + vi.restoreAllMocks() + fs.rmSync(root, { recursive: true, force: true }) + }) + + async function invoke(channel: string, ...args: unknown[]) { + const handler = vi.mocked(ipcMain.handle).mock.calls.find(([name]) => name === channel)![1] + return handler({} as Electron.IpcMainInvokeEvent, ...args) + } + + function migrate(owned: boolean, target: StandaloneTargetSelection = { mode: 'auto' }) { + return migrateToStandaloneFromSnapshot( + { + installNameBase: 'Migrated', + stagedSnapshot: { path: snapshotFile, owned }, + sourcePaths: {}, + labels: { userData: '', input: '', output: '', models: '' }, + target + }, + { + sourceMap: { standalone }, + sendProgress: vi.fn(), + sendOutput: vi.fn(), + uniqueName: vi.fn(), + signal: new AbortController().signal + } + ) + } + + it('returns a localized build-installation failure instead of rejecting the invoke', async () => { + await expect(invoke('build-installation', 'standalone', { release, variant })).resolves.toEqual( + failure + ) + expect(installations.add).not.toHaveBeenCalled() + }) + + it('returns successful build data separately from its status', async () => { + variant.data!.manifest = { comfyui_ref: '0.18.3', python_version: '3.13.12' } + await expect( + invoke('build-installation', 'standalone', { release, variant }) + ).resolves.toMatchObject({ + ok: true, + data: { sourceId: 'standalone', variant: 'linux-cpu', pythonVersion: '3.13.12' } + }) + }) + + it('returns a structured failure for an unknown source', async () => { + await expect(invoke('build-installation', 'missing', {})).resolves.toEqual({ + ok: false, + message: lookupEnMessage('errors.unknownSource') + }) + }) + + it('returns a release-update failure before creating a directory or installation', async () => { + const inst = { + id: 'old', + sourceId: 'standalone', + installPath: root + } as installations.InstallationRecord + await expect( + handleReleaseUpdate({ + event: {} as Electron.IpcMainInvokeEvent, + installationId: inst.id, + inst, + actionData: { name: 'Updated', releaseSelection: release, variantSelection: variant } + }) + ).resolves.toEqual(failure) + expect(installations.add).not.toHaveBeenCalled() + expect(fs.readdirSync(root)).toEqual(['snapshot.json']) + }) + + it('returns a create-from-snapshot failure before staging or adding an installation', async () => { + const copy = vi.spyOn(fs.promises, 'copyFile') + await expect( + invoke('create-from-snapshot', snapshotFile, 'New', 'stable', 'linux-cpu') + ).resolves.toEqual(failure) + expect(copy).not.toHaveBeenCalled() + expect(installations.add).not.toHaveBeenCalled() + expect(fs.existsSync(snapshotFile)).toBe(true) + }) + + it.each([ + { mode: 'auto', owned: true }, + { mode: 'auto', owned: false }, + { mode: 'selected', owned: true }, + { mode: 'selected', owned: false } + ] as const)( + 'awaits migration cleanup with $mode selections and owned=$owned', + async ({ mode, owned }) => { + const target: StandaloneTargetSelection = + mode === 'selected' ? { mode, release, variant } : { mode } + await expect(migrate(owned, target)).rejects.toThrow(failure.message) + expect(fs.existsSync(snapshotFile)).toBe(!owned) + expect(installations.add).not.toHaveBeenCalled() + } + ) + + it.each([ + { field: 'release', message: 'No releases available.' }, + { field: 'variant', message: 'No compatible variants found for this platform.' } + ])('cleans up an owned snapshot when the $field catalog is empty', async ({ field, message }) => { + vi.mocked(standalone.getFieldOptions!).mockImplementation(async (id) => + id === field ? [] : [release] + ) + await expect(migrate(true)).rejects.toThrow(message) + expect(fs.existsSync(snapshotFile)).toBe(false) + expect(installations.add).not.toHaveBeenCalled() + }) + + it('cleans up an owned snapshot when loading the catalog rejects', async () => { + vi.mocked(standalone.getFieldOptions!).mockRejectedValue(new Error('Catalog unavailable')) + await expect(migrate(true)).rejects.toThrow('Catalog unavailable') + expect(fs.existsSync(snapshotFile)).toBe(false) + expect(installations.add).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/lib/ipc/registerAppHandlers.ts b/src/main/lib/ipc/registerAppHandlers.ts index 0ee4231bd..15110d072 100644 --- a/src/main/lib/ipc/registerAppHandlers.ts +++ b/src/main/lib/ipc/registerAppHandlers.ts @@ -35,6 +35,7 @@ import { getCloudFreeRunsEnabledAsync } from '../cloudFreeRuns' import { getUserTierAsync } from '../userTier' import { getStableTags } from '../comfyui-releases' import { deriveGpuTier } from '../../../shared/gpuTier' +import { tryBuildInstallation } from '../buildInstallation' export function registerAppHandlers(): void { // App version @@ -112,15 +113,11 @@ export function registerAppHandlers(): void { ipcMain.handle( 'build-installation', - (_event, sourceId: string, selections: Record) => { - const source = sourceMap[sourceId] - if (!source) return null - return { - sourceId: source.id, - sourceLabel: source.label, - ...source.buildInstallation(selections as Record) - } - } + (_event, sourceId: string, selections: Record) => + tryBuildInstallation( + sourceMap[sourceId], + selections as Record + ) ) // Paths diff --git a/src/main/lib/ipc/registerSnapshotHandlers.ts b/src/main/lib/ipc/registerSnapshotHandlers.ts index dd45294c3..efb5b4a5a 100644 --- a/src/main/lib/ipc/registerSnapshotHandlers.ts +++ b/src/main/lib/ipc/registerSnapshotHandlers.ts @@ -44,6 +44,7 @@ import { stripPlatform } from '../../sources/standalone/envPaths' import type { InstallationRecord } from '../../installations' +import { tryBuildInstallation } from '../buildInstallation' /** * Kept-local disclosure for an import about to be restored: when the @@ -593,10 +594,13 @@ export function registerSnapshotHandlers(): void { buildPinnedVariant(selectedRelease, matched.data?.variantId as string, pinTag, gpu?.id) ?? matched + const buildResult = tryBuildInstallation(source, { + release: selectedRelease, + variant: installVariant + }) + if (!buildResult.ok) return buildResult const instData = { - sourceId: source.id, - sourceLabel: source.label, - ...source.buildInstallation({ release: selectedRelease, variant: installVariant }), + ...buildResult.data, // Freeze to the snapshot's pinned ComfyUI version. Even with the exact // bundle pinned above, buildInstallation would set autoUpdateComfyUI: // true for a stable/latest channel and auto-update to latest before the diff --git a/src/main/lib/ipc/sessionActions/copy.ts b/src/main/lib/ipc/sessionActions/copy.ts index 94c6250e7..d44f6a59f 100644 --- a/src/main/lib/ipc/sessionActions/copy.ts +++ b/src/main/lib/ipc/sessionActions/copy.ts @@ -24,6 +24,7 @@ import type { FieldOption, InstallationRecord } from '../shared' import { parseAnyIndexStackId } from '../../../sources/standalone/torchStackTypes' import type { ActionContext, ActionResult } from './types' import { withAbortableSessionAction } from './withAbortable' +import { tryBuildInstallation } from '../../buildInstallation' export async function handleCopy(ctx: ActionContext): Promise { const { event, installationId, inst, actionData } = ctx @@ -201,10 +202,12 @@ export async function handleReleaseUpdate(ctx: ActionContext): Promise, - cleanupOnError: () => void + sourceMap: Record ): Promise<{ instData: Record; standaloneSource: SourcePlugin }> { const standaloneSource = sourceMap['standalone']! @@ -122,7 +122,6 @@ async function resolveStandaloneInstallData( { includeLatestStable: true } ) if (releaseOptions.length === 0) { - cleanupOnError() throw new Error('No releases available.') } release = releaseOptions[0]! @@ -134,16 +133,15 @@ async function resolveStandaloneInstallData( { gpu: gpu?.id } ) if (variantOptions.length === 0) { - cleanupOnError() throw new Error('No compatible variants found for this platform.') } variant = variantOptions.find((v) => v.recommended) || variantOptions[0]! } + const buildResult = tryBuildInstallation(standaloneSource, { release, variant }) + if (!buildResult.ok) throw new Error(buildResult.message) const instData = { - sourceId: 'standalone', - sourceLabel: standaloneSource.label, - ...standaloneSource.buildInstallation({ release, variant }), + ...buildResult.data, // Migrating from a snapshot freezes the install to the snapshot's pinned // ComfyUI version: skip the post-install auto-update (the snapshot restore // re-pins the core commit). updateChannel is left as built here and @@ -545,16 +543,19 @@ export async function migrateToStandaloneFromSnapshot( const { sendProgress, signal, uniqueName } = tools const { stagedSnapshot, sourcePaths, labels, target } = input - const cleanupStagedFile = (): void => { - if (stagedSnapshot.owned) fs.promises.unlink(stagedSnapshot.path).catch(() => {}) + const cleanupStagedFile = async (): Promise => { + if (stagedSnapshot.owned) await fs.promises.unlink(stagedSnapshot.path).catch(() => {}) } // 1. Resolve release/variant - const { instData, standaloneSource } = await telemetry.trackedStep( - 'comfy.desktop.migrate.resolve_target', - {}, - async () => resolveStandaloneInstallData(target, tools.sourceMap, cleanupStagedFile) - ) + const { instData, standaloneSource } = await telemetry + .trackedStep('comfy.desktop.migrate.resolve_target', {}, async () => + resolveStandaloneInstallData(target, tools.sourceMap) + ) + .catch(async (error: unknown) => { + await cleanupStagedFile() + throw error + }) // 2. Create new standalone installation record const { entry, destPath } = await telemetry.trackedStep( @@ -694,7 +695,7 @@ export async function migrateToStandaloneFromSnapshot( // owned staged file here. await installations.remove(entry.id).catch(() => {}) await fs.promises.rm(destPath, { recursive: true, force: true }).catch(() => {}) - cleanupStagedFile() + await cleanupStagedFile() } else { await installations.update(entry.id, { status: 'failed' }).catch(() => {}) } diff --git a/src/renderer/src/panel/useFirstUseChain.test.ts b/src/renderer/src/panel/useFirstUseChain.test.ts index 0fa5dde1f..3dd5ba3e5 100644 --- a/src/renderer/src/panel/useFirstUseChain.test.ts +++ b/src/renderer/src/panel/useFirstUseChain.test.ts @@ -69,7 +69,7 @@ function buildApi(overrides: Partial = {}): TestApi { }), buildInstallation: vi .fn() - .mockResolvedValue({ sourceId: 'standalone', sourceCategory: 'local' }), + .mockResolvedValue({ ok: true, data: { sourceId: 'standalone', sourceCategory: 'local' } }), getUniqueName: vi.fn().mockResolvedValue('ComfyUI'), addInstallation: vi .fn() @@ -228,6 +228,16 @@ describe('useFirstUseChain — Express Install', () => { expect(chain.switchPanel).toHaveBeenCalledWith('new-install', 'first_use') }) + it('falls back to Configure on a structured build validation failure', async () => { + testApi.buildInstallation.mockResolvedValue({ ok: false, message: 'Runtime unavailable' }) + const chain = mountChain() + await chain.api!.handleFirstUseChainLocal({ express: true }) + + expect(chain.switchPanel).toHaveBeenCalledWith('new-install', 'first_use') + expect(testApi.addInstallation).not.toHaveBeenCalled() + expect(chain.handleShowProgress).not.toHaveBeenCalled() + }) + it('opens Configure when `express` is omitted (legacy chain-local behaviour)', async () => { const chain = mountChain() await chain.api!.handleFirstUseChainLocal() diff --git a/src/renderer/src/panel/useFirstUseChain.ts b/src/renderer/src/panel/useFirstUseChain.ts index 3396a58c1..ada9b7646 100644 --- a/src/renderer/src/panel/useFirstUseChain.ts +++ b/src/renderer/src/panel/useFirstUseChain.ts @@ -390,14 +390,20 @@ export function useFirstUseChain(opts: FirstUseChainOpts): FirstUseChainApi { selections[field.id] = pick } - const instData = await window.api.buildInstallation(standalone.id, selections) + const buildResult = await window.api.buildInstallation(standalone.id, selections) + if (!buildResult.ok) { + emitTelemetryAction('comfy.desktop.install.express.fallback', { + reason: 'precondition_failed' + }) + return false + } const name = await window.api.getUniqueName(DEFAULT_INSTALL_NAME) const installPath = installDir ?? '' const result = await window.api.addInstallation({ name, installPath, - ...instData, + ...buildResult.data, status: 'installing' }) if (!result.ok || !result.entry) { diff --git a/src/renderer/src/views/InstallWizardModal.test.ts b/src/renderer/src/views/InstallWizardModal.test.ts index a068b315c..c2456fe6c 100644 --- a/src/renderer/src/views/InstallWizardModal.test.ts +++ b/src/renderer/src/views/InstallWizardModal.test.ts @@ -39,7 +39,7 @@ beforeEach(() => { getUniqueName: vi.fn().mockResolvedValue('ComfyUI'), getDiskSpace: vi.fn().mockResolvedValue(null), validateInstallPath: vi.fn().mockResolvedValue([]), - buildInstallation: vi.fn().mockResolvedValue({ sourceId: 'standalone' }), + buildInstallation: vi.fn().mockResolvedValue({ ok: true, data: { sourceId: 'standalone' } }), addInstallation: vi.fn().mockResolvedValue({ ok: true }), getInstallations: vi.fn().mockResolvedValue([]), onInstallationsChanged: vi.fn(() => () => {}), @@ -167,7 +167,7 @@ describe('InstallWizardModal standalone runtime availability', () => { fields: [{ id: 'url', label: 'URL', type: 'text', defaultValue: 'http://localhost:8188' }] } ]) - vi.mocked(window.api.buildInstallation).mockResolvedValue({ sourceId: id }) + vi.mocked(window.api.buildInstallation).mockResolvedValue({ ok: true, data: { sourceId: id } }) await openWithOptions({}) await wrapper .findAll('button[role="radio"]') @@ -185,9 +185,10 @@ describe('InstallWizardModal standalone runtime availability', () => { }) it('shows main-process validation errors without adding an installation', async () => { - vi.mocked(window.api.buildInstallation).mockRejectedValue( - new Error(en.standalone.invalidRuntime) - ) + vi.mocked(window.api.buildInstallation).mockResolvedValue({ + ok: false, + message: en.standalone.invalidRuntime + }) await openWithOptions({ release: [release], variant: [variant] }) await wrapper.get('.config-continue').trigger('click') await flushPromises() diff --git a/src/renderer/src/views/InstallWizardModal.vue b/src/renderer/src/views/InstallWizardModal.vue index 98e3c74f4..55c96d952 100644 --- a/src/renderer/src/views/InstallWizardModal.vue +++ b/src/renderer/src/views/InstallWizardModal.vue @@ -1077,7 +1077,13 @@ async function handleSave(): Promise { let instData: Record try { - instData = await window.api.buildInstallation(source.id, rawSelections()) + const buildResult = await window.api.buildInstallation(source.id, rawSelections()) + if (!buildResult.ok) { + sourceError.value = buildResult.message + step.value = 'configure' + return + } + instData = buildResult.data } catch (error) { sourceError.value = error instanceof Error ? error.message : String(error) step.value = 'configure' diff --git a/src/renderer/src/views/QuickInstallModal.test.ts b/src/renderer/src/views/QuickInstallModal.test.ts new file mode 100644 index 000000000..cefb928c0 --- /dev/null +++ b/src/renderer/src/views/QuickInstallModal.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import QuickInstallModal from './QuickInstallModal.vue' +import { en } from '../lib/i18nMessages' + +const alert = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +vi.mock('../composables/useModal', () => ({ useModal: () => ({ alert }) })) + +describe('QuickInstallModal build results', () => { + let wrapper: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + window.api = { + getDefaultInstallDir: vi.fn().mockResolvedValue('/tmp/ComfyUI'), + getSources: vi + .fn() + .mockResolvedValue([{ id: 'standalone', label: 'Standalone', fields: [] }]), + detectGPU: vi.fn().mockResolvedValue(null), + validateHardware: vi.fn().mockResolvedValue({ supported: true }), + getFieldOptions: vi + .fn() + .mockImplementation(async (_source, field) => + field === 'release' + ? [{ value: 'latest', label: 'Latest' }] + : [{ value: 'cpu', label: 'CPU', data: { variantId: 'cpu' } }] + ), + getDiskSpace: vi.fn().mockResolvedValue(null), + validateInstallPath: vi.fn().mockResolvedValue([]), + buildInstallation: vi.fn(), + getUniqueName: vi.fn().mockResolvedValue('ComfyUI'), + addInstallation: vi + .fn() + .mockResolvedValue({ ok: true, entry: { id: 'new', name: 'ComfyUI' } }) + } as unknown as typeof window.api + wrapper = mount(QuickInstallModal, { + global: { stubs: { ModalShell: { template: '
' } } } + }) + await (wrapper.vm as unknown as { open: () => Promise }).open() + await flushPromises() + }) + + afterEach(() => wrapper.unmount()) + + it('shows the localized validation message under Cannot Add and permits retry', async () => { + vi.mocked(window.api.buildInstallation).mockResolvedValue({ + ok: false, + message: en.standalone.invalidRuntime + }) + await wrapper.get('.quick-install-btn').trigger('click') + await flushPromises() + + expect(alert).toHaveBeenCalledExactlyOnceWith({ + title: en.errors.cannotAdd, + message: en.standalone.invalidRuntime + }) + expect(window.api.addInstallation).not.toHaveBeenCalled() + expect(wrapper.emitted('show-progress')).toBeUndefined() + expect(wrapper.get('.quick-install-btn').element.disabled).toBe(false) + }) + + it('passes successful build data to installation creation', async () => { + const data = { sourceId: 'standalone', variant: 'cpu' } + vi.mocked(window.api.buildInstallation).mockResolvedValue({ ok: true, data }) + await wrapper.get('.quick-install-btn').trigger('click') + await flushPromises() + + expect(window.api.addInstallation).toHaveBeenCalledExactlyOnceWith({ + ...data, + name: 'ComfyUI', + installPath: '/tmp/ComfyUI', + status: 'installing' + }) + expect(alert).not.toHaveBeenCalled() + expect(wrapper.emitted('show-progress')?.[0]?.[0]).toMatchObject({ installationId: 'new' }) + }) +}) diff --git a/src/renderer/src/views/QuickInstallModal.vue b/src/renderer/src/views/QuickInstallModal.vue index 31c0234b9..ed85f79f9 100644 --- a/src/renderer/src/views/QuickInstallModal.vue +++ b/src/renderer/src/views/QuickInstallModal.vue @@ -240,14 +240,19 @@ async function handleInstall(): Promise { } } - const instData = await window.api.buildInstallation('standalone', rawSelections()) + const buildResult = await window.api.buildInstallation('standalone', rawSelections()) + if (!buildResult.ok) { + await modal.alert({ title: t('errors.cannotAdd'), message: buildResult.message }) + installing.value = false + return + } const baseName = instName.value.trim() || DEFAULT_INSTALL_NAME const name = await window.api.getUniqueName(baseName) const result = await window.api.addInstallation({ name, installPath: instPath.value, - ...instData, + ...buildResult.data, status: 'installing' }) diff --git a/src/types/ipc.ts b/src/types/ipc.ts index ad03bd690..401a448c4 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -158,6 +158,10 @@ export interface RunningInstance { } // --- Source / New Install types --- +export type BuildInstallationResult = + | { ok: true; data: Record } + | { ok: false; message: string } + export interface Source { id: string label: string @@ -1093,7 +1097,7 @@ export interface ElectronApi { buildInstallation( sourceId: string, selections: Record - ): Promise> + ): Promise getDefaultInstallDir(): Promise detectGPU(): Promise validateHardware(): Promise