Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connect: Show config file errors #22728

Merged
merged 16 commits into from
Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
58 changes: 37 additions & 21 deletions web/packages/teleterm/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,15 @@ if (app.requestSingleInstanceLock()) {
app.exit(1);
}

function initializeApp(): void {
async function initializeApp(): Promise<void> {
ravicious marked this conversation as resolved.
Show resolved Hide resolved
let devRelaunchScheduled = false;
const settings = getRuntimeSettings();
const logger = initMainLogger(settings);
const appStateFileStorage = createFileStorage({
filePath: path.join(settings.userDataDir, 'app_state.json'),
debounceWrites: true,
});
const configFileStorage = createFileStorage({
filePath: path.join(settings.userDataDir, 'app_config.json'),
debounceWrites: false,
});
const configJsonSchemaFileStorage = createFileStorage({
filePath: path.join(settings.userDataDir, 'schema_app_config.json'),
debounceWrites: false,
});
const {
appStateFileStorage,
configFileStorage,
configJsonSchemaFileStorage,
} = await createFileStorages(settings.userDataDir);

const configService = createConfigService({
configFile: configFileStorage,
Expand Down Expand Up @@ -97,16 +90,17 @@ function initializeApp(): void {

app.on('will-quit', async event => {
event.preventDefault();
const disposeMainProcess = async () => {
ravicious marked this conversation as resolved.
Show resolved Hide resolved
try {
await mainProcess.dispose();
} catch (e) {
logger.error('Failed to gracefully dispose of main process', e);
}
};

appStateFileStorage.writeSync();
globalShortcut.unregisterAll();
try {
await mainProcess.dispose();
} catch (e) {
logger.error('Failed to gracefully dispose of main process', e);
} finally {
app.exit();
}
await Promise.all([appStateFileStorage.write(), disposeMainProcess()]); // none of them can throw
gzdunek marked this conversation as resolved.
Show resolved Hide resolved
app.exit();
});

app.on('quit', () => {
Expand Down Expand Up @@ -204,3 +198,25 @@ function initMainLogger(settings: types.RuntimeSettings) {

return new Logger('Main');
}

function createFileStorages(userDataDir: string) {
return Promise.all([
createFileStorage({
filePath: path.join(userDataDir, 'app_state.json'),
debounceWrites: true,
}),
createFileStorage({
filePath: path.join(userDataDir, 'app_config.json'),
debounceWrites: false,
discardUpdatesOnLoadError: true,
}),
createFileStorage({
filePath: path.join(userDataDir, 'schema_app_config.json'),
debounceWrites: false,
}),
]).then(storages => ({
appStateFileStorage: storages[0],
configFileStorage: storages[1],
configJsonSchemaFileStorage: storages[2],
}));
}
5 changes: 3 additions & 2 deletions web/packages/teleterm/src/mainProcess/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,14 @@ export enum TabContextMenuEventType {
export enum ConfigServiceEventType {
Get = 'Get',
Set = 'Set',
GetStoredConfigErrors = 'GetStoredConfigErrors',
GetConfigError = 'GetConfigError',
}

export enum FileStorageEventType {
Get = 'Get',
Put = 'Put',
WriteSync = 'WriteSync',
Write = 'Write',
Replace = 'Replace',
GetFilePath = 'GetFilePath',
GetFileLoadingError = 'GetFileLoadingError',
}
2 changes: 1 addition & 1 deletion web/packages/teleterm/src/mainProcess/windowsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export class WindowsManager {
}

private getWindowState(): WindowState {
const windowState = this.fileStorage.get<WindowState>(this.storageKey);
const windowState = this.fileStorage.get(this.storageKey) as WindowState;
const getDefaults = () => ({
height: 720,
width: 1280,
Expand Down
40 changes: 30 additions & 10 deletions web/packages/teleterm/src/services/config/configService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ test('stored and default values are combined', () => {
platform: 'darwin',
});

expect(configService.getStoredConfigErrors()).toBeUndefined();
expect(configService.getConfigError()).toBeUndefined();

const usageReportingEnabled = configService.get('usageReporting.enabled');
expect(usageReportingEnabled.value).toBe(true);
Expand All @@ -52,15 +52,18 @@ test('in case of invalid value a default one is returned', () => {
platform: 'darwin',
});

expect(configService.getStoredConfigErrors()).toStrictEqual([
{
code: 'invalid_type',
expected: 'boolean',
received: 'string',
message: 'Expected boolean, received string',
path: ['usageReporting.enabled'],
},
]);
expect(configService.getConfigError()).toStrictEqual({
source: 'validation',
errors: [
{
code: 'invalid_type',
expected: 'boolean',
received: 'string',
message: 'Expected boolean, received string',
path: ['usageReporting.enabled'],
},
],
});

const usageReportingEnabled = configService.get('usageReporting.enabled');
expect(usageReportingEnabled.value).toBe(false);
Expand All @@ -71,6 +74,23 @@ test('in case of invalid value a default one is returned', () => {
expect(terminalFontSize.metadata.isStored).toBe(false);
});

test('if config file failed to load correctly the error is returned', () => {
const configFile = createMockFileStorage();
const error = new Error('Failed to read');
jest.spyOn(configFile, 'getFileLoadingError').mockReturnValue(error);

const configService = createConfigService({
configFile,
jsonSchemaFile: createMockFileStorage(),
platform: 'darwin',
});

expect(configService.getConfigError()).toStrictEqual({
source: 'file-loading',
error,
});
});

test('calling set updated the value in store', () => {
const configFile = createMockFileStorage();
const configService = createConfigService({
Expand Down
52 changes: 43 additions & 9 deletions web/packages/teleterm/src/services/config/configService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,35 @@ import {

const logger = new Logger('ConfigService');

type FileLoadingError = {
source: 'file-loading';
error: Error;
};

type ValidationError = {
source: 'validation';
errors: ZodIssue[];
};

type ConfigError = FileLoadingError | ValidationError;

export interface ConfigService {
get<K extends keyof AppConfig>(
key: K
): { value: AppConfig[K]; metadata: { isStored: boolean } };

set<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void;

getStoredConfigErrors(): ZodIssue[] | undefined;
/**
* Returns validation errors or an error that occurred during loading the config file (this means IO and syntax errors).
* This error has to be checked during the initialization of the app.
*
* The reason we have a getter for this error instead of making `createConfigService` fail with an error
* is that in the presence of this error we want to notify about it and then continue with default values:
* - If validation errors occur, the incorrect values are replaced with the defaults.
* - In case of an error coming from loading the file, all values are replaced with the defaults.
* */
getConfigError(): ConfigError | undefined;
ravicious marked this conversation as resolved.
Show resolved Hide resolved
}

export function createConfigService({
Expand All @@ -53,10 +74,11 @@ export function createConfigService({
const schema = createAppConfigSchema(platform);
updateJsonSchema({ schema, configFile, jsonSchemaFile });

const { storedConfig, configWithDefaults, errors } = validateStoredConfig(
schema,
configFile
);
const {
storedConfig,
configWithDefaults,
errors: validationErrors,
} = validateStoredConfig(schema, configFile);

return {
get: key => ({
Expand All @@ -68,7 +90,21 @@ export function createConfigService({
configWithDefaults[key] = value;
storedConfig[key] = value;
},
getStoredConfigErrors: () => errors,
getConfigError: () => {
const fileLoadingError = configFile.getFileLoadingError();
if (fileLoadingError) {
return {
source: 'file-loading',
error: fileLoadingError,
};
}
if (validationErrors) {
return {
source: 'validation',
errors: validationErrors,
};
}
},
};
}

Expand Down Expand Up @@ -96,8 +132,6 @@ function updateJsonSchema({
}
}

//TODO (gzdunek): syntax errors of the JSON file are silently ignored, report
// them to the user too
function validateStoredConfig(
schema: AppConfigSchema,
configFile: FileStorage
Expand All @@ -108,7 +142,7 @@ function validateStoredConfig(
} {
const parse = (data: Partial<AppConfig>) => schema.safeParse(data);

const storedConfig = configFile.get<Partial<AppConfig>>();
const storedConfig = configFile.get() as Partial<AppConfig>;
const parsed = parse(storedConfig);
if (parsed.success === true) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export function subscribeToConfigServiceEvents(
return (event.returnValue = configService.get(item.path));
case ConfigServiceEventType.Set:
return configService.set(item.path, item.value);
case ConfigServiceEventType.GetStoredConfigErrors:
return (event.returnValue = configService.getStoredConfigErrors());
case ConfigServiceEventType.GetConfigError:
return (event.returnValue = configService.getConfigError());
}
}
);
Expand All @@ -54,10 +54,10 @@ export function createConfigServiceClient(): ConfigService {
value,
});
},
getStoredConfigErrors: () => {
getConfigError: () => {
return ipcRenderer.sendSync(
ConfigServiceEventChannel,
ConfigServiceEventType.GetStoredConfigErrors
ConfigServiceEventType.GetConfigError
);
},
};
Expand Down
4 changes: 2 additions & 2 deletions web/packages/teleterm/src/services/config/fixtures/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export function createMockConfigService(
set(key, value) {
values[key] = value;
},
getStoredConfigErrors() {
return [];
getConfigError() {
return undefined;
},
};
}