fix(electron): preserve selected runtime ownership during repair
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dependencies: vi.fn(async () => true),
|
||||
runtimeConfig: null as { root: string; owned: boolean } | null,
|
||||
existingProject: false,
|
||||
dependencies: vi.fn(async (_project?: string) => true),
|
||||
ready: vi.fn(async () => false),
|
||||
compatible: vi.fn(async () => false),
|
||||
interrupted: vi.fn(async () => false),
|
||||
@@ -14,6 +16,27 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/voicestudio' } }));
|
||||
vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() }));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...original,
|
||||
readFileSync: (...args: Parameters<typeof original.readFileSync>) =>
|
||||
String(args[0]).endsWith('runtime-location.json') && mocks.runtimeConfig
|
||||
? JSON.stringify(mocks.runtimeConfig)
|
||||
: original.readFileSync(...args),
|
||||
writeFileSync: (...args: Parameters<typeof original.writeFileSync>) => {
|
||||
if (String(args[0]).endsWith('runtime-location.json')) {
|
||||
mocks.runtimeConfig = JSON.parse(String(args[1]));
|
||||
return;
|
||||
}
|
||||
return original.writeFileSync(...args);
|
||||
},
|
||||
mkdirSync: (...args: Parameters<typeof original.mkdirSync>) =>
|
||||
String(args[0]).includes('private') ? undefined : original.mkdirSync(...args),
|
||||
existsSync: (path: Parameters<typeof original.existsSync>[0]) =>
|
||||
mocks.existingProject && String(path).includes('selected') ? true : original.existsSync(path),
|
||||
};
|
||||
});
|
||||
vi.mock('node:fs/promises', () => ({ rm: mocks.rm }));
|
||||
vi.mock('./runtime-project', () => ({
|
||||
runtimeDependenciesReady: mocks.dependencies,
|
||||
@@ -36,6 +59,8 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
mocks.runtimeConfig = null;
|
||||
mocks.existingProject = false;
|
||||
mocks.dependencies.mockResolvedValue(true);
|
||||
mocks.ready.mockResolvedValue(false);
|
||||
mocks.compatible.mockResolvedValue(false);
|
||||
@@ -392,3 +417,34 @@ it.each(['ready', 'compatible'] as const)(
|
||||
await supervisor.shutdown();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a selected broken environment selected until explicit setup, then preserves unowned files', async () => {
|
||||
const { resolve, join } = await import('node:path');
|
||||
const selected = resolve('/selected/VoiceStudio');
|
||||
mocks.runtimeConfig = { root: selected, owned: false };
|
||||
mocks.existingProject = true;
|
||||
mocks.ready.mockResolvedValue(true);
|
||||
mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected'));
|
||||
mocks.install.mockRejectedValue(new Error('offline'));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('no backend');
|
||||
}),
|
||||
);
|
||||
vi.stubEnv('OMNIVOICE_BACKEND_CMD', '');
|
||||
vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', '');
|
||||
const supervisor = new BackendSupervisor();
|
||||
await supervisor.start();
|
||||
expect(supervisor.status.stage).toBe('setup_required');
|
||||
expect(
|
||||
mocks.dependencies.mock.calls.every(([project]) => String(project).includes('selected')),
|
||||
).toBe(true);
|
||||
expect(mocks.install).not.toHaveBeenCalled();
|
||||
await supervisor.setupRuntime();
|
||||
expect(mocks.install.mock.calls[0][1]).toBe(join('/private/voicestudio', 'runtime', 'project'));
|
||||
expect(mocks.runtimeConfig?.root).not.toBe(selected);
|
||||
expect(mocks.rm).not.toHaveBeenCalled();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
await supervisor.shutdown();
|
||||
});
|
||||
|
||||
@@ -520,7 +520,7 @@ export class BackendSupervisor extends EventEmitter<{
|
||||
this.stage !== 'setup_required'
|
||||
)
|
||||
return;
|
||||
const project =
|
||||
let project =
|
||||
this.runtimeProject ?? join(storedRuntimeRoot() ?? defaultRuntimeRoot(), 'project');
|
||||
this.runtimeProject = project;
|
||||
const controller = new AbortController();
|
||||
@@ -543,8 +543,27 @@ export class BackendSupervisor extends EventEmitter<{
|
||||
await this.start();
|
||||
return;
|
||||
}
|
||||
const runtimeRoot = dirname(project);
|
||||
let runtimeRoot = dirname(project);
|
||||
const configured = storedRuntimeLocation();
|
||||
if (
|
||||
configured &&
|
||||
!configured.owned &&
|
||||
samePath(configured.root, runtimeRoot) &&
|
||||
!samePath(runtimeRoot, defaultRuntimeRoot()) &&
|
||||
existsSync(project)
|
||||
) {
|
||||
// An explicit setup action may create a new runtime, but must never
|
||||
// take ownership of (or repair in place) another installation's files.
|
||||
runtimeRoot = defaultRuntimeRoot();
|
||||
project = join(runtimeRoot, 'project');
|
||||
this.runtimeProject = project;
|
||||
writeRuntimeLocation(runtimeRoot, true);
|
||||
this.pushLog(
|
||||
'out',
|
||||
'Creating a separate Electron runtime; existing environment preserved.',
|
||||
);
|
||||
this.emitStatus();
|
||||
}
|
||||
if (
|
||||
configured &&
|
||||
samePath(configured.root, runtimeRoot) &&
|
||||
@@ -801,12 +820,10 @@ export class BackendSupervisor extends EventEmitter<{
|
||||
const own = join(defaultRuntimeRoot(), 'project');
|
||||
const configuredRoot = storedRuntimeRoot();
|
||||
const configured = configuredRoot ? join(configuredRoot, 'project') : null;
|
||||
const candidates = [
|
||||
this.runtimeProject,
|
||||
configured,
|
||||
own,
|
||||
...legacyTauriRuntimeProjects(),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
// Explicit selection is authoritative, including when it needs setup.
|
||||
const candidates = (
|
||||
configured ? [configured] : [this.runtimeProject, own, ...legacyTauriRuntimeProjects()]
|
||||
).filter((candidate): candidate is string => Boolean(candidate));
|
||||
for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) {
|
||||
if (
|
||||
((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) &&
|
||||
|
||||
Reference in New Issue
Block a user