Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,8 @@
"adoptStepRegister": "注册安装"
},
"standalone": {
"runtimeUnavailable": "没有可用的兼容独立版运行环境。请稍后重试,或使用远程连接。",
"invalidRuntime": "创建实例前,请选择发布版本和兼容的运行环境。",
"_note": "standalone.label 和 portable.label 是产品名称——所有翻译中请保持英文。",
"label": "独立版",
"desc": "预构建环境,包含托管 Python 和依赖。",
Expand Down
24 changes: 24 additions & 0 deletions src/main/lib/buildInstallation.ts
Original file line number Diff line number Diff line change
@@ -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<string, FieldOption | undefined>
): 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) }
}
}
215 changes: 215 additions & 0 deletions src/main/lib/installationBuild.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GpuModule>()),
detectGPU: vi.fn().mockResolvedValue(null)
}))
vi.mock('./i18n', async (importOriginal) => ({
...(await importOriginal<typeof I18nModule>()),
t: (await import('./localeTestHelper')).lookupEnMessage
}))
vi.mock('./telemetry', () => ({
trackedStep: async <T>(_name: string, _ctx: unknown, fn: () => Promise<T>) => 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()
})
})
15 changes: 6 additions & 9 deletions src/main/lib/ipc/registerAppHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -112,15 +113,11 @@ export function registerAppHandlers(): void {

ipcMain.handle(
'build-installation',
(_event, sourceId: string, selections: Record<string, unknown>) => {
const source = sourceMap[sourceId]
if (!source) return null
return {
sourceId: source.id,
sourceLabel: source.label,
...source.buildInstallation(selections as Record<string, FieldOption | undefined>)
}
}
(_event, sourceId: string, selections: Record<string, unknown>) =>
tryBuildInstallation(
sourceMap[sourceId],
selections as Record<string, FieldOption | undefined>
)
)

// Paths
Expand Down
Loading
Loading