Update local client configs in case of version change (#10377)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-01-08 22:20:14 +07:00
committed by GitHub
parent cdbc1959af
commit 5a30880702
3 changed files with 617 additions and 10 deletions
+522
View File
@@ -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')
})
})
})
})
+93 -9
View File
@@ -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)
}
+2 -1
View File
@@ -1,4 +1,5 @@
{
"server": "https://app.tracex.co/",
"updatesChannelKey": "tracex"
"updatesChannelKey": "tracex",
"_version": "1.0.0"
}