diff --git a/desktop/src/__test__/main/config.test.ts b/desktop/src/__test__/main/config.test.ts new file mode 100644 index 0000000000..43262a6ed6 --- /dev/null +++ b/desktop/src/__test__/main/config.test.ts @@ -0,0 +1,522 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Mock electron app before importing config +const mockApp = { + getPath: jest.fn((name: string) => { + if (name === 'userData') { + return '/mock/userData' + } + return `/mock/${name}` + }), + getName: jest.fn(() => 'TestApp') +} + +jest.mock('electron', () => ({ + app: mockApp +})) + +// Mock fs module +const mockFs = { + existsSync: jest.fn(), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn() +} + +jest.mock('fs', () => mockFs) + +import { PackedConfig, readPackedConfig } from '../../main/config' + +describe('config', () => { + let originalResourcesPath: string | undefined + let originalVersion: string | undefined + let originalConsoleError: typeof console.error + let originalConsoleLog: typeof console.log + + beforeEach(() => { + originalResourcesPath = (process as any).resourcesPath + originalVersion = process.env.VERSION + originalConsoleError = console.error + originalConsoleLog = console.log + + // Setup default mocks + ;(process as any).resourcesPath = '/mock/resources' + process.env.VERSION = '1.0.0' + console.error = jest.fn() + console.log = jest.fn() + + // Reset all mocks + jest.clearAllMocks() + mockFs.existsSync.mockReturnValue(false) + mockFs.readFileSync.mockImplementation(() => { + throw new Error('File not found') + }) + mockFs.writeFileSync.mockReturnValue(undefined) + mockFs.mkdirSync.mockReturnValue(undefined) + }) + + afterEach(() => { + ;(process as any).resourcesPath = originalResourcesPath + if (originalVersion !== undefined) { + process.env.VERSION = originalVersion + } else { + delete process.env.VERSION + } + console.error = originalConsoleError + console.log = originalConsoleLog + }) + + describe('readPackedConfig', () => { + describe('migration on first run', () => { + test('migrates bundled config to userData when userData config does not exist', () => { + const bundledConfig: PackedConfig = { + server: 'https://app.example.com', + updatesChannelKey: 'test' + } + + let writeCallCount = 0 + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') { + // After migration, file exists + return writeCallCount > 0 + } + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(bundledConfig) + } + if (filePath === '/mock/userData/config.json' && writeCallCount > 0) { + // Return migrated config after write + return JSON.stringify({ ...bundledConfig, _version: '1.0.0' }) + } + throw new Error('File not found') + }) + + mockFs.writeFileSync.mockImplementation(() => { + writeCallCount++ + }) + + const result = readPackedConfig() + + expect(result).toEqual({ + ...bundledConfig, + _version: '1.0.0' + }) + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/mock/userData/config.json', + JSON.stringify({ ...bundledConfig, _version: '1.0.0' }, null, 2), + 'utf8' + ) + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Migrated config from bundled location'), + expect.any(String) + ) + }) + + test('creates userData directory if it does not exist', () => { + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return false + if (filePath === '/mock/userData/config.json') return false + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockReturnValue(JSON.stringify({ server: 'https://app.example.com' })) + + readPackedConfig() + + expect(mockFs.mkdirSync).toHaveBeenCalledWith('/mock/userData', { recursive: true }) + }) + }) + + describe('config update on version change', () => { + test('updates userData config when bundled config version is newer', () => { + const oldUserDataConfig: PackedConfig = { + server: 'https://old.server.com', + updatesChannelKey: 'old', + _version: '0.9.0' + } + + const newBundledConfig: PackedConfig = { + server: 'https://new.server.com', + updatesChannelKey: 'new' + } + + let readCallCount = 0 + let writeCallCount = 0 + const mergedConfig = { + ...oldUserDataConfig, + ...newBundledConfig, + _version: '1.0.0' + } + + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return true + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + readCallCount++ + if (filePath === '/mock/userData/config.json') { + if (readCallCount === 1) { + // First read: old userData config (during migration check) + return JSON.stringify(oldUserDataConfig) + } + // After write, return merged config + if (writeCallCount > 0) { + return JSON.stringify(mergedConfig) + } + return JSON.stringify(oldUserDataConfig) + } + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(newBundledConfig) + } + throw new Error('File not found') + }) + + mockFs.writeFileSync.mockImplementation(() => { + writeCallCount++ + }) + + const result = readPackedConfig() + + // Merged config: userData base, bundled overwrites, version updated + expect(result).toEqual({ + server: 'https://new.server.com', // Bundled overwrites + updatesChannelKey: 'new', // Bundled overwrites + _version: '1.0.0' // Updated to bundled version + }) + + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/mock/userData/config.json', + JSON.stringify(mergedConfig, null, 2), + 'utf8' + ) + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Updated userData config with new bundled config values'), + expect.any(String) + ) + }) + + test('does not update userData config when versions match', () => { + const userDataConfig: PackedConfig = { + server: 'https://user.server.com', + _version: '1.0.0' + } + + const bundledConfig: PackedConfig = { + server: 'https://bundled.server.com' + } + + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return true + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData/config.json') { + return JSON.stringify(userDataConfig) + } + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(bundledConfig) + } + return '{}' + }) + + const result = readPackedConfig() + + // Should return userData config without modification + expect(result).toEqual(userDataConfig) + expect(mockFs.writeFileSync).not.toHaveBeenCalled() + }) + }) + + describe('reading config', () => { + test('reads from userData config when it exists', () => { + const userDataConfig: PackedConfig = { + server: 'https://userdata.server.com', + updatesChannelKey: 'userdata' + } + + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData/config.json') { + return JSON.stringify(userDataConfig) + } + return '{}' + }) + + const result = readPackedConfig() + + expect(result).toEqual(userDataConfig) + expect(mockFs.readFileSync).toHaveBeenCalledWith('/mock/userData/config.json', 'utf8') + }) + + test('falls back to bundled config when userData config does not exist', () => { + const bundledConfig: PackedConfig = { + server: 'https://bundled.server.com' + } + + let writeCallCount = 0 + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') { + // After migration, file exists + return writeCallCount > 0 + } + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(bundledConfig) + } + if (filePath === '/mock/userData/config.json' && writeCallCount > 0) { + return JSON.stringify({ ...bundledConfig, _version: '1.0.0' }) + } + throw new Error('File not found') + }) + + mockFs.writeFileSync.mockImplementation(() => { + writeCallCount++ + }) + + const result = readPackedConfig() + + expect(result).toEqual({ + ...bundledConfig, + _version: '1.0.0' + }) + }) + + test('returns undefined when no config exists', () => { + mockFs.existsSync.mockReturnValue(false) + mockFs.readFileSync.mockImplementation(() => { + throw new Error('File not found') + }) + + const result = readPackedConfig() + + expect(result).toBeUndefined() + }) + }) + + describe('error handling', () => { + test('handles corrupted userData config by replacing with bundled config', () => { + const bundledConfig: PackedConfig = { + server: 'https://bundled.server.com' + } + + let readCallCount = 0 + let writeCallCount = 0 + const replacedConfig = { ...bundledConfig, _version: '1.0.0' } + + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return true + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + readCallCount++ + if (filePath === '/mock/userData/config.json') { + if (readCallCount === 1) { + // First read fails (corrupted) - this happens in migrateConfigIfNeeded + throw new Error('Invalid JSON') + } + // After replacement, return bundled config - this happens in readPackedConfig + if (writeCallCount > 0) { + return JSON.stringify(replacedConfig) + } + throw new Error('Invalid JSON') + } + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(bundledConfig) + } + throw new Error('File not found') + }) + + mockFs.writeFileSync.mockImplementation(() => { + writeCallCount++ + }) + + const result = readPackedConfig() + + expect(result).toEqual(replacedConfig) + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/mock/userData/config.json', + JSON.stringify(replacedConfig, null, 2), + 'utf8' + ) + expect(console.error).toHaveBeenCalled() + }) + + test('handles missing bundled config gracefully', () => { + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return false + if (filePath === '/mock/resources/config/config.json') return false + return false + }) + + mockFs.readFileSync.mockImplementation(() => { + throw new Error('File not found') + }) + + const result = readPackedConfig() + + expect(result).toBeUndefined() + expect(mockFs.writeFileSync).not.toHaveBeenCalled() + }) + + test('handles file system errors during migration', () => { + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return false + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation(() => { + throw new Error('Permission denied') + }) + + const result = readPackedConfig() + + expect(result).toBeUndefined() + // Migration catches errors, so we should see the error logged + expect(console.error).toHaveBeenCalled() + }) + }) + + describe('merge behavior', () => { + test('preserves userData fields not in bundled config', () => { + const userDataConfig: PackedConfig = { + server: 'https://old.server.com', + updatesChannelKey: 'custom', + _version: '0.9.0', + // @ts-expect-error - testing custom field preservation + customField: 'user-value' + } + + const bundledConfig: PackedConfig = { + server: 'https://new.server.com' + // updatesChannelKey not in bundled - when spread, undefined values don't overwrite + } + + let readCallCount = 0 + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return true + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + readCallCount++ + if (filePath === '/mock/userData/config.json') { + if (readCallCount === 1) { + return JSON.stringify(userDataConfig) + } + // After merge: userData base, bundled overwrites defined fields only + // Since updatesChannelKey is not in bundled, it's preserved from userData + const merged = { + ...userDataConfig, + ...bundledConfig, + _version: '1.0.0' + } + // updatesChannelKey from userData is preserved because bundled doesn't have it + merged.updatesChannelKey = userDataConfig.updatesChannelKey + return JSON.stringify(merged) + } + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(bundledConfig) + } + throw new Error('File not found') + }) + + const result = readPackedConfig() + + // Bundled overwrites server, but userData updatesChannelKey should be preserved + expect(result?.server).toBe('https://new.server.com') + expect(result?.updatesChannelKey).toBe('custom') // Preserved from userData (not in bundled) + expect(result?._version).toBe('1.0.0') + }) + + test('handles QMS server URL update scenario', () => { + const oldUserDataConfig: PackedConfig = { + server: 'https://old.tracex.co', + updatesChannelKey: 'tracex', + _version: '0.7.100' + } + + const newBundledConfig: PackedConfig = { + server: 'https://app.tracex.co/', + updatesChannelKey: 'tracex' + } + + let readCallCount = 0 + mockFs.existsSync.mockImplementation((filePath: string) => { + if (filePath === '/mock/userData') return true + if (filePath === '/mock/userData/config.json') return true + if (filePath === '/mock/resources/config/config.json') return true + return false + }) + + mockFs.readFileSync.mockImplementation((filePath: string) => { + readCallCount++ + if (filePath === '/mock/userData/config.json') { + if (readCallCount === 1) { + return JSON.stringify(oldUserDataConfig) + } + // After merge, return merged config + return JSON.stringify({ + ...oldUserDataConfig, + ...newBundledConfig, + _version: '1.0.0' + }) + } + if (filePath === '/mock/resources/config/config.json') { + return JSON.stringify(newBundledConfig) + } + throw new Error('File not found') + }) + + const result = readPackedConfig() + + // Server URL should be updated from bundled config + expect(result?.server).toBe('https://app.tracex.co/') + expect(result?.updatesChannelKey).toBe('tracex') + expect(result?._version).toBe('1.0.0') + }) + }) + }) +}) diff --git a/desktop/src/main/config.ts b/desktop/src/main/config.ts index ea6d211224..05bac87ab0 100644 --- a/desktop/src/main/config.ts +++ b/desktop/src/main/config.ts @@ -12,22 +12,106 @@ // See the License for the specific language governing permissions and // limitations under the License. // +import { app } from 'electron' import * as path from 'path' import * as fs from 'fs' export interface PackedConfig { server?: string updatesChannelKey?: string + _version?: string } -const configPath = path.join(process.resourcesPath, 'config', 'config.json') - -export function readPackedConfig (): PackedConfig | undefined { - if (fs.existsSync(configPath)) { - try { - return JSON.parse(fs.readFileSync(configPath, 'utf8')) as PackedConfig - } catch (err) { - console.log('Failed to read packed config', err) - } +/** + * Reads a JSON config file, returning undefined on error. + */ +function readConfigFile (filePath: string): PackedConfig | undefined { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackedConfig + } catch (err) { + console.error(`Failed to read config from ${filePath}:`, err) + return undefined } } + +/** + * Writes a JSON config file, logging errors. + */ +function writeConfigFile (filePath: string, config: PackedConfig): boolean { + try { + fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8') + return true + } catch (err) { + console.error(`Failed to write config to ${filePath}:`, err) + return false + } +} + +/** + * Migrates and updates config from bundled resourcesPath to app-specific userData location. + * Handles config updates when a new version is installed with a new bundled config. + */ +function migrateConfigIfNeeded (): void { + try { + const userDataConfigPath = path.join(app.getPath('userData'), 'config.json') + const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json') + + const userDataDir = app.getPath('userData') + if (!fs.existsSync(userDataDir)) { + fs.mkdirSync(userDataDir, { recursive: true }) + } + + const bundledConfig = readConfigFile(resourcesConfigPath) + if (bundledConfig === undefined) { + return + } + + bundledConfig._version = process.env.VERSION + + if (!fs.existsSync(userDataConfigPath)) { + if (writeConfigFile(userDataConfigPath, bundledConfig)) { + console.log('Migrated config from bundled location to app-specific userData:', userDataConfigPath) + } + return + } + + const userDataConfig = readConfigFile(userDataConfigPath) + if (userDataConfig === undefined) { + writeConfigFile(userDataConfigPath, bundledConfig) + return + } + + const bundledVersion = bundledConfig._version ?? '' + const userDataVersion = userDataConfig._version ?? '' + if (bundledVersion !== userDataVersion && bundledVersion !== '') { + const mergedConfig: PackedConfig = { + ...userDataConfig, + ...bundledConfig, + _version: bundledVersion + } + if (writeConfigFile(userDataConfigPath, mergedConfig)) { + console.log('Updated userData config with new bundled config values, version:', bundledVersion) + } + } + } catch (err) { + console.error('Failed to migrate config:', err) + } +} + +function getConfigPath (): string { + return path.join(app.getPath('userData'), 'config.json') +} + +export function readPackedConfig (): PackedConfig | undefined { + migrateConfigIfNeeded() + + const configPath = getConfigPath() + const config = readConfigFile(configPath) + if (config !== undefined) { + return config + } + + // Fallback to bundled config if userData config doesn't exist (shouldn't happen after migration) + const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json') + return readConfigFile(resourcesConfigPath) +} diff --git a/qms-desktop-package/src/config/config.json b/qms-desktop-package/src/config/config.json index 621e42a05e..dfbb2cdc98 100644 --- a/qms-desktop-package/src/config/config.json +++ b/qms-desktop-package/src/config/config.json @@ -1,4 +1,5 @@ { "server": "https://app.tracex.co/", - "updatesChannelKey": "tracex" + "updatesChannelKey": "tracex", + "_version": "1.0.0" } \ No newline at end of file