Centralize log file and refactor desktop logging

- Migrate to `electron-log` v5.X.X, centralizing log files to adhere to
  best-practices.
- Add critical event logging in the log file.
- Replace `ElectronLog` type with `LogFunctions` for better abstraction.
- Unify log handling in `desktop-runtime-error` by removing
  `renderer.log` due to `electron-log` v5 changes.
- Update and extend logger interfaces, removing 'I' prefix and adding
  common log levels to abstract `electron-log` completely.
- Move logger interfaces to the application layer as it's cross-cutting
  concern, meanwhile keeping the implementations in the infrastructure
  layer.
- Introduce `useLogger` hook for easier logging in Vue components.
- Simplify `WindowVariables` by removing nullable properties.
- Improve documentation to clearly differentiate between desktop and web
  versions, outlining specific features of each.
This commit is contained in:
undergroundwires
2023-12-02 11:50:25 +01:00
parent 8f5d7ed3cf
commit 08dbfead7c
40 changed files with 347 additions and 191 deletions

View File

@@ -124,9 +124,9 @@
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-Setup-0.12.8.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-0.12.8.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-0.12.8.AppImage). For more options, see [here](#additional-install-options).
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
For a detailed comparison of features between the desktop and web versions of privacy.sexy, see [Desktop vs. Web Features](./docs/desktop-vs-web-features.md).
💡 You should apply your configuration from time to time (more than once). It would strengthen your privacy and security control because privacy.sexy and its scripts get better and stronger in every new version.
💡 Regularly applying your configuration with privacy.sexy is recommended, especially after each new release and major operating system updates. Each version updates scripts to enhance stability, privacy, and security.
[![privacy.sexy application](img/screenshot.png?raw=true )](https://privacy.sexy)

View File

@@ -0,0 +1,36 @@
# Desktop vs. Web Features
This table outlines the differences between the desktop and web versions of `privacy.sexy`.
| Feature | Desktop | Web |
| ------- |---------|-----|
| [Usage without installation](#usage-without-installation) | 🔴 Not available | 🟢 Available |
| [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available |
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
| [Logging](#logging) | 🟢 Available | 🔴 Not available |
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
## Feature Descriptions
### Usage without installation
The web version can be used directly in a browser without any installation, whereas the desktop version requires downloading and installing the software.
> **Note for Linux:** For Linux users, privacy.sexy is available as an AppImage, which is a portable format that does not require traditional installation. This means Linux users can use the desktop version without installation, similar to the web version.
### Offline usage
Once loaded, the web version can be used offline. The desktop version inherently supports offline usage.
### Auto-updates
Both versions automatically update to ensure you have the latest features and security enhancements.
### Logging
The desktop version supports logging of activities to aid in troubleshooting. This feature is not available in the web version.
### Script execution
Direct execution of scripts is possible in the desktop version, offering a more integrated experience.
This functionality is not present in the web version due to browser limitations.

19
package-lock.json generated
View File

@@ -6,14 +6,14 @@
"packages": {
"": {
"name": "privacy.sexy",
"version": "0.12.7",
"version": "0.12.8",
"hasInstallScript": true,
"dependencies": {
"@floating-ui/vue": "^1.0.2",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.30.0",
"cross-fetch": "^4.0.0",
"electron-log": "^4.4.8",
"electron-log": "^5.0.1",
"electron-progressbar": "^2.1.0",
"electron-updater": "^6.1.4",
"file-saver": "^2.0.5",
@@ -6699,9 +6699,12 @@
}
},
"node_modules/electron-log": {
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz",
"integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA==",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-progressbar": {
"version": "2.1.0",
@@ -24483,9 +24486,9 @@
}
},
"electron-log": {
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz",
"integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA=="
},
"electron-progressbar": {
"version": "2.1.0",

View File

@@ -37,7 +37,7 @@
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.30.0",
"cross-fetch": "^4.0.0",
"electron-log": "^4.4.8",
"electron-log": "^5.0.1",
"electron-progressbar": "^2.1.0",
"electron-updater": "^6.1.4",
"file-saver": "^2.0.5",

View File

@@ -0,0 +1,6 @@
export interface Logger {
info(...params: unknown[]): void;
warn(...params: unknown[]): void;
error(...params: unknown[]): void;
debug(...params: unknown[]): void;
}

View File

@@ -0,0 +1,5 @@
import { Logger } from '@/application/Common/Log/Logger';
export interface LoggerFactory {
readonly logger: Logger;
}

View File

@@ -1,17 +1,32 @@
import { ILogger } from './ILogger';
import { Logger } from '@/application/Common/Log/Logger';
export class ConsoleLogger implements ILogger {
constructor(private readonly consoleProxy: Partial<Console> = console) {
export class ConsoleLogger implements Logger {
constructor(private readonly consoleProxy: ConsoleLogFunctions = globalThis.console) {
if (!consoleProxy) { // do not trust strictNullChecks for global objects
throw new Error('missing console');
}
}
public info(...params: unknown[]): void {
const logFunction = this.consoleProxy?.info;
if (!logFunction) {
throw new Error('missing "info" function');
this.consoleProxy.info(...params);
}
logFunction.call(this.consoleProxy, ...params);
public warn(...params: unknown[]): void {
this.consoleProxy.warn(...params);
}
public error(...params: unknown[]): void {
this.consoleProxy.error(...params);
}
public debug(...params: unknown[]): void {
this.consoleProxy.debug(...params);
}
}
interface ConsoleLogFunctions extends Partial<Console> {
readonly info: Console['info'];
readonly warn: Console['warn'];
readonly error: Console['error'];
readonly debug: Console['debug'];
}

View File

@@ -1,17 +1,15 @@
import { ElectronLog } from 'electron-log';
import { ILogger } from './ILogger';
import log from 'electron-log/main';
import { Logger } from '@/application/Common/Log/Logger';
import type { LogFunctions } from 'electron-log';
// Using plain-function rather than class so it can be used in Electron's context-bridging.
export function createElectronLogger(logger: Partial<ElectronLog>): ILogger {
if (!logger) {
throw new Error('missing logger');
}
export function createElectronLogger(logger: LogFunctions = log): Logger {
return {
info: (...params) => {
if (!logger.info) {
throw new Error('missing "info" function');
}
logger.info(...params);
},
info: (...params) => logger.info(...params),
debug: (...params) => logger.debug(...params),
warn: (...params) => logger.warn(...params),
error: (...params) => logger.error(...params),
};
}
export const ElectronLogger = createElectronLogger();

View File

@@ -1,3 +0,0 @@
export interface ILogger {
info (...params: unknown[]): void;
}

View File

@@ -1,5 +0,0 @@
import { ILogger } from './ILogger';
export interface ILoggerFactory {
readonly logger: ILogger;
}

View File

@@ -1,5 +1,11 @@
import { ILogger } from './ILogger';
import { Logger } from '@/application/Common/Log/Logger';
export class NoopLogger implements ILogger {
export class NoopLogger implements Logger {
public info(): void { /* NOOP */ }
public warn(): void { /* NOOP */ }
public error(): void { /* NOOP */ }
public debug(): void { /* NOOP */ }
}

View File

@@ -1,8 +1,8 @@
import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '../WindowVariables/WindowVariables';
import { ILogger } from './ILogger';
export class WindowInjectedLogger implements ILogger {
private readonly logger: ILogger;
export class WindowInjectedLogger implements Logger {
private readonly logger: Logger;
constructor(windowVariables: WindowVariables | undefined | null = window) {
if (!windowVariables) { // do not trust strict null checks for global objects
@@ -17,4 +17,16 @@ export class WindowInjectedLogger implements ILogger {
public info(...params: unknown[]): void {
this.logger.info(...params);
}
public warn(...params: unknown[]): void {
this.logger.warn(...params);
}
public debug(...params: unknown[]): void {
this.logger.debug(...params);
}
public error(...params: unknown[]): void {
this.logger.error(...params);
}
}

View File

@@ -1,11 +1,11 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
/* Primary entry point for platform-specific injections */
export interface WindowVariables {
readonly isDesktop?: boolean;
readonly isDesktop: boolean;
readonly system?: ISystemOperations;
readonly os?: OperatingSystem;
readonly log?: ILogger;
readonly log: Logger;
}

View File

@@ -1,15 +1,15 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { ILoggerFactory } from '@/infrastructure/Log/ILoggerFactory';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
export class ClientLoggerFactory implements ILoggerFactory {
public static readonly Current: ILoggerFactory = new ClientLoggerFactory();
export class ClientLoggerFactory implements LoggerFactory {
public static readonly Current: LoggerFactory = new ClientLoggerFactory();
public readonly logger: ILogger;
public readonly logger: Logger;
protected constructor(environment: IRuntimeEnvironment = RuntimeEnvironment.CurrentEnvironment) {
if (environment.isDesktop) {

View File

@@ -12,6 +12,7 @@ import {
} from '@/presentation/injectionSymbols';
import { PropertyKeys } from '@/TypeHelpers';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
export function provideDependencies(
context: IApplicationContext,
@@ -57,6 +58,10 @@ export function provideDependencies(
return useUserSelectionState(state, events);
},
),
useLogger: (di) => di.provide(
InjectionKeys.useLogger,
useLogger,
),
};
registerAll(Object.values(resolvers), api);
}

View File

@@ -1,10 +1,10 @@
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { Bootstrapper } from '../Bootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements Bootstrapper {
constructor(
private readonly logger: ILogger = ClientLoggerFactory.Current.logger,
private readonly logger: Logger = ClientLoggerFactory.Current.logger,
) { }
public async bootstrap(): Promise<void> {

View File

@@ -17,16 +17,18 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { dumpNames } from './DumpNames';
export default defineComponent({
setup() {
const { log } = injectKey((keys) => keys.useLogger);
const devActions: readonly DevAction[] = [
{
name: 'Log script/category names',
handler: async () => {
const names = await dumpNames();
console.log(names);
log.info(names);
},
},
];

View File

@@ -0,0 +1,8 @@
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { ClientLoggerFactory } from '@/presentation/bootstrapping/ClientLoggerFactory';
export function useLogger(factory: LoggerFactory = ClientLoggerFactory.Current) {
return {
log: factory.logger,
};
}

View File

@@ -1,7 +1,7 @@
import { app, dialog } from 'electron';
import { autoUpdater, UpdateInfo } from 'electron-updater';
import { ProgressInfo } from 'electron-builder';
import log from 'electron-log';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { UpdateProgressBar } from './UpdateProgressBar';
export async function handleAutoUpdate() {
@@ -23,11 +23,11 @@ function startHandlingUpdateProgress() {
On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished.
*/
log.debug('@download-progress@\n', progress);
ElectronLogger.debug('@download-progress@\n', progress);
progressBar.showProgress(progress);
});
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
log.info('@update-downloaded@\n', info);
ElectronLogger.info('@update-downloaded@\n', info);
progressBar.close();
await handleUpdateDownloaded();
});

View File

@@ -2,12 +2,12 @@ import fs from 'fs';
import path from 'path';
import { app, dialog, shell } from 'electron';
import { UpdateInfo } from 'electron-updater';
import log from 'electron-log';
import fetch from 'cross-fetch';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Version } from '@/domain/Version';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { UpdateProgressBar } from './UpdateProgressBar';
export function requiresManualUpdate(): boolean {
@@ -64,16 +64,16 @@ async function askForVisitingWebsiteForManualUpdate(): Promise<ManualDownloadDia
}
async function download(info: UpdateInfo, project: ProjectInformation) {
log.info('Downloading update manually');
ElectronLogger.info('Downloading update manually');
const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState();
try {
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${info.version}-installer.dmg`;
const parentFolder = path.dirname(filePath);
if (fs.existsSync(filePath)) {
log.info('Update is already downloaded');
ElectronLogger.info('Update is already downloaded');
await fs.promises.unlink(filePath);
log.info(`Deleted ${filePath}`);
ElectronLogger.info(`Deleted ${filePath}`);
} else {
await fs.promises.mkdir(parentFolder, { recursive: true });
}
@@ -99,20 +99,20 @@ async function downloadFileWithProgress(
progressHandler: ProgressCallback,
) {
// We don't download through autoUpdater as it cannot download DMG but requires distributing ZIP
log.info(`Fetching ${url}`);
ElectronLogger.info(`Fetching ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw Error(`Unable to download, server returned ${response.status} ${response.statusText}`);
}
const contentLengthString = response.headers.get('content-length');
if (contentLengthString === null || contentLengthString === undefined) {
log.error('Content-Length header is missing');
ElectronLogger.error('Content-Length header is missing');
}
const contentLength = +(contentLengthString ?? 0);
const writer = fs.createWriteStream(filePath);
log.info(`Writing to ${filePath}, content length: ${contentLength}`);
ElectronLogger.info(`Writing to ${filePath}, content length: ${contentLength}`);
if (Number.isNaN(contentLength) || contentLength <= 0) {
log.error('Unknown content-length', Array.from(response.headers.entries()));
ElectronLogger.error('Unknown content-length', Array.from(response.headers.entries()));
progressHandler = () => { /* do nothing */ };
}
const reader = getReader(response);
@@ -137,9 +137,9 @@ async function streamWithProgress(
receivedLength += chunk.length;
const percentage = Math.floor((receivedLength / totalLength) * 100);
progressHandler(percentage);
log.debug(`Received ${receivedLength} of ${totalLength}`);
ElectronLogger.debug(`Received ${receivedLength} of ${totalLength}`);
}
log.info('Downloaded successfully');
ElectronLogger.info('Downloaded successfully');
}
function getReader(response: Response): NodeJS.ReadableStream | undefined {

View File

@@ -1,7 +1,7 @@
import ProgressBar from 'electron-progressbar';
import { ProgressInfo } from 'electron-builder';
import { app, BrowserWindow } from 'electron';
import log from 'electron-log';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
export class UpdateProgressBar {
private progressBar: ProgressBar | undefined;
@@ -81,7 +81,7 @@ const progressBarFactory = {
progressBar.detail = 'Download completed.';
})
.on('aborted', (value: number) => {
log.info(`progress aborted... ${value}`);
ElectronLogger.info(`Progress aborted... ${value}`);
})
.on('progress', (value: number) => {
progressBar.detail = `${value}% ...`;

View File

@@ -1,5 +1,5 @@
import { autoUpdater, UpdateInfo } from 'electron-updater';
import log from 'electron-log';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { handleManualUpdate, requiresManualUpdate } from './ManualUpdater';
import { handleAutoUpdate } from './AutoUpdater';
@@ -8,21 +8,21 @@ interface IUpdater {
}
export function setupAutoUpdater(): IUpdater {
autoUpdater.logger = log;
autoUpdater.logger = ElectronLogger;
// Disable autodownloads because "checking" and "downloading" are handled separately based on the
// current platform and user's choice.
autoUpdater.autoDownload = false;
autoUpdater.on('error', (error: Error) => {
log.error('@error@\n', error);
ElectronLogger.error('@error@\n', error);
});
let isAlreadyHandled = false;
autoUpdater.on('update-available', async (info: UpdateInfo) => {
log.info('@update-available@\n', info);
ElectronLogger.info('@update-available@\n', info);
if (isAlreadyHandled) {
log.info('Available updates is already handled');
ElectronLogger.info('Available updates is already handled');
return;
}
isAlreadyHandled = true;

View File

@@ -3,9 +3,10 @@
import {
app, protocol, BrowserWindow, shell, screen,
} from 'electron';
import log from 'electron-log/main';
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { setupAutoUpdater } from './Update/Updater';
import {
APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL,
@@ -23,6 +24,7 @@ protocol.registerSchemesAsPrivileged([
]);
setupLogger();
validateRuntimeSanity({
// Metadata is used by manual updates.
validateEnvironmentVariables: true,
@@ -89,7 +91,7 @@ app.on('ready', async () => {
try {
await installExtension(VUEJS_DEVTOOLS);
} catch (e) {
log.error('Vue Devtools failed to install:', e.toString());
ElectronLogger.error('Vue Devtools failed to install:', e.toString());
}
}
createWindow();
@@ -123,7 +125,7 @@ function loadApplication(window: BrowserWindow) {
updater.checkForUpdates();
}
// Do not remove [WINDOW_INIT]; it's a marker used in tests.
log.info('[WINDOW_INIT] Main window initialized and content loading.');
ElectronLogger.info('[WINDOW_INIT] Main window initialized and content loading.');
}
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
@@ -150,5 +152,7 @@ function getWindowSize(idealWidth: number, idealHeight: number) {
}
function setupLogger(): void {
// log.initialize(); ← We inject logger to renderer through preloader, this is not needed.
log.transports.file.level = 'silly';
log.eventLogger.startLogging();
}

View File

@@ -1,13 +1,12 @@
import log from 'electron-log';
import { createNodeSystemOperations } from '@/infrastructure/SystemOperations/NodeSystemOperations';
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { convertPlatformToOs } from './NodeOsMapper';
export function provideWindowVariables(
createSystem = createNodeSystemOperations,
createLogger: () => ILogger = () => createElectronLogger(log),
createLogger: () => Logger = () => createElectronLogger(),
convertToOs = convertPlatformToOs,
): WindowVariables {
return {

View File

@@ -1,8 +1,8 @@
// This file is used to securely expose Electron APIs to the application.
import { contextBridge } from 'electron';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { provideWindowVariables } from './WindowVariablesProvider';
validateRuntimeSanity({
@@ -20,4 +20,4 @@ Object.entries(windowVariables).forEach(([key, value]) => {
});
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
log.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');
ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');

View File

@@ -6,6 +6,7 @@ import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipbo
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -15,6 +16,7 @@ export const InjectionKeys = {
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
};
export interface InjectionKeyWithLifetime<T> {

View File

@@ -5,14 +5,11 @@ import { exists } from '../utils/io';
import { SupportedPlatform, CURRENT_PLATFORM } from '../utils/platform';
import { getAppName } from '../utils/npm';
const LOG_FILE_NAMES = ['main', 'renderer'];
export async function clearAppLogFiles(
projectDir: string,
): Promise<void> {
if (!projectDir) { throw new Error('missing project directory'); }
await Promise.all(LOG_FILE_NAMES.map(async (logFileName) => {
const logPath = await determineLogPath(projectDir, logFileName);
const logPath = await determineLogPath(projectDir);
if (!logPath || !await exists(logPath)) {
log(`Skipping clearing logs, log file does not exist: ${logPath}.`);
return;
@@ -23,15 +20,13 @@ export async function clearAppLogFiles(
} catch (error) {
die(`Failed to clear the log file at: ${logPath}. Reason: ${error}`);
}
}));
}
export async function readAppLogFile(
projectDir: string,
logFileName: string,
): Promise<AppLogFileResult> {
if (!projectDir) { throw new Error('missing project directory'); }
const logPath = await determineLogPath(projectDir, logFileName);
const logPath = await determineLogPath(projectDir);
if (!logPath || !await exists(logPath)) {
log(`No log file at: ${logPath}`, LogLevel.Warn);
return {
@@ -52,10 +47,9 @@ interface AppLogFileResult {
async function determineLogPath(
projectDir: string,
logFileName: string,
): Promise<string> {
if (!projectDir) { throw new Error('missing project directory'); }
if (!LOG_FILE_NAMES.includes(logFileName)) { throw new Error(`unknown log file name: ${logFileName}`); }
const logFileName = 'main.log';
const appName = await getAppName(projectDir);
if (!appName) {
return die('App name not found.');
@@ -67,19 +61,19 @@ async function determineLogPath(
if (!process.env.HOME) {
throw new Error('HOME environment variable is not defined');
}
return join(process.env.HOME, 'Library', 'Logs', appName, `${logFileName}.log`);
return join(process.env.HOME, 'Library', 'Logs', appName, logFileName);
},
[SupportedPlatform.Linux]: () => {
if (!process.env.HOME) {
throw new Error('HOME environment variable is not defined');
}
return join(process.env.HOME, '.config', appName, 'logs', `${logFileName}.log`);
return join(process.env.HOME, '.config', appName, 'logs', logFileName);
},
[SupportedPlatform.Windows]: () => {
if (!process.env.USERPROFILE) {
throw new Error('USERPROFILE environment variable is not defined');
}
return join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', `${logFileName}.log`);
return join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', logFileName);
},
};
const logFilePath = logFilePaths[CURRENT_PLATFORM]?.();

View File

@@ -1,4 +1,4 @@
import { splitTextIntoLines, indentText, filterEmpty } from '../utils/text';
import { splitTextIntoLines, indentText } from '../utils/text';
import { log, die } from '../utils/log';
import { readAppLogFile } from './app-logs';
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
@@ -11,8 +11,6 @@ const EXPECTED_LOG_MARKERS = [
'[APP_INIT]',
];
type ProcessType = 'main' | 'renderer';
export async function checkForErrors(
stderr: string,
windowTitles: readonly string[],
@@ -31,13 +29,11 @@ async function gatherErrors(
projectDir: string,
): Promise<ExecutionError[]> {
if (!projectDir) { throw new Error('missing project directory'); }
const { logFileContent: mainLogs, logFilePath: mainLogFile } = await readAppLogFile(projectDir, 'main');
const { logFileContent: rendererLogs, logFilePath: rendererLogFile } = await readAppLogFile(projectDir, 'renderer');
const allLogs = filterEmpty([mainLogs, rendererLogs, stderr]).join('\n');
const { logFileContent: mainLogs, logFilePath: mainLogFile } = await readAppLogFile(projectDir);
const allLogs = [mainLogs, stderr].filter(Boolean).join('\n');
return [
verifyStdErr(stderr),
verifyApplicationLogsExist('main', mainLogs, mainLogFile),
verifyApplicationLogsExist('renderer', rendererLogs, rendererLogFile),
verifyApplicationLogsExist(mainLogs, mainLogFile),
...EXPECTED_LOG_MARKERS.map(
(marker) => verifyLogMarkerExistsInLogs(allLogs, marker),
),
@@ -72,13 +68,12 @@ function formatError(error: ExecutionError): string {
}
function verifyApplicationLogsExist(
processType: ProcessType,
logContent: string | undefined,
logFilePath: string,
): ExecutionError | undefined {
if (!logContent?.length) {
return describeError(
`Missing application (${processType}) logs`,
'Missing application logs',
'Application logs are empty not were not found.'
+ `\nLog path: ${logFilePath}`,
);

View File

@@ -32,21 +32,6 @@ describe('ConsoleLogger', () => {
expect(consoleMock.callHistory[0].args).to.deep.equal(expectedParams);
});
});
describe('throws if log function is missing', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedError = `missing "${functionName}" function`;
const consoleMock = {} as Partial<Console>;
consoleMock[functionName] = undefined;
const logger = new ConsoleLogger(consoleMock);
// act
const act = () => logger[functionName](...testParameters);
// assert
expect(act).to.throw(expectedError);
});
});
});
class MockConsole
@@ -58,4 +43,25 @@ class MockConsole
args,
});
}
public warn(...args: unknown[]) {
this.registerMethodCall({
methodName: 'warn',
args,
});
}
public debug(...args: unknown[]) {
this.registerMethodCall({
methodName: 'debug',
args,
});
}
public error(...args: unknown[]) {
this.registerMethodCall({
methodName: 'error',
args,
});
}
}

View File

@@ -1,42 +1,15 @@
import { describe, expect } from 'vitest';
import { ElectronLog } from 'electron-log';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itEachLoggingMethod } from './LoggerTestRunner';
import type { LogFunctions } from 'electron-log';
describe('ElectronLogger', () => {
describe('throws if logger is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing logger';
const electronLog = absentValue as never;
// act
const act = () => createElectronLogger(electronLog);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true });
});
describe('throws if log function is missing', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedError = `missing "${functionName}" function`;
const electronLogMock = {} as Partial<ElectronLog>;
electronLogMock[functionName] = undefined;
const logger = createElectronLogger(electronLogMock);
// act
const act = () => logger[functionName](...testParameters);
// assert
expect(act).to.throw(expectedError);
});
});
describe('methods log the provided params', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedParams = testParameters;
const electronLogMock = new MockElectronLog();
const electronLogMock = new ElectronLogStub();
const logger = createElectronLogger(electronLogMock);
// act
@@ -50,9 +23,51 @@ describe('ElectronLogger', () => {
});
});
class MockElectronLog
extends StubWithObservableMethodCalls<ElectronLog>
implements Partial<ElectronLog> {
class ElectronLogStub
extends StubWithObservableMethodCalls<LogFunctions>
implements LogFunctions {
public error(...args: unknown[]) {
this.registerMethodCall({
methodName: 'error',
args,
});
}
public warn(...args: unknown[]) {
this.registerMethodCall({
methodName: 'warn',
args,
});
}
public verbose(...args: unknown[]): void {
this.registerMethodCall({
methodName: 'verbose',
args,
});
}
public debug(...args: unknown[]) {
this.registerMethodCall({
methodName: 'debug',
args,
});
}
public silly(...args: unknown[]) {
this.registerMethodCall({
methodName: 'silly',
args,
});
}
public log(...args: unknown[]) {
this.registerMethodCall({
methodName: 'log',
args,
});
}
public info(...args: unknown[]) {
this.registerMethodCall({
methodName: 'info',

View File

@@ -1,23 +1,25 @@
import { it } from 'vitest';
import { FunctionKeys } from '@/TypeHelpers';
import { ILogger } from '@/infrastructure/Log/ILogger';
type TestParameters = [string, number, { some: string }];
import { Logger } from '@/application/Common/Log/Logger';
export function itEachLoggingMethod(
handler: (
functionName: keyof ILogger,
testParameters: TestParameters,
functionName: keyof Logger,
testParameters: readonly unknown[]
) => void,
) {
const testParameters: TestParameters = ['test', 123, { some: 'object' }];
const loggerMethods: Array<FunctionKeys<ILogger>> = [
'info',
];
loggerMethods
.forEach((functionKey) => {
const testScenarios: {
readonly [FunctionName in keyof Logger]: Parameters<Logger[FunctionName]>;
} = {
info: ['single-string'],
warn: ['with number', 123],
debug: ['with simple object', { some: 'object' }],
error: ['with error object', new Error('error')],
};
Object.entries(testScenarios)
.forEach(([functionKey, testParameters]) => {
it(functionKey, () => {
handler(functionKey, testParameters);
handler(functionKey as keyof Logger, testParameters);
});
});
}

View File

@@ -1,6 +1,6 @@
import { describe, expect } from 'vitest';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { itEachLoggingMethod } from './LoggerTestRunner';
describe('NoopLogger', () => {
@@ -8,7 +8,7 @@ describe('NoopLogger', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const randomParams = testParameters;
const logger: ILogger = new NoopLogger();
const logger: Logger = new NoopLogger();
// act
const act = () => logger[functionName](...randomParams);

View File

@@ -177,10 +177,11 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
describe('does not object type when not on desktop', () => {
itEachInvalidObjectValue((invalidObjectValue) => {
// arrange
const isOnDesktop = false;
const invalidObject = invalidObjectValue as T;
const input: WindowVariables = {
...new WindowVariablesStub(),
isDesktop: undefined,
isDesktop: isOnDesktop,
[key]: invalidObject,
};
// act

View File

@@ -3,7 +3,7 @@ import {
} from 'vitest';
import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment';
import { ClientLoggerFactory } from '@/presentation/bootstrapping/ClientLoggerFactory';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
@@ -29,7 +29,7 @@ describe('ClientLoggerFactory', () => {
});
const testCases: Array<{
readonly description: string,
readonly expectedType: Constructible<ILogger>,
readonly expectedType: Constructible<Logger>,
readonly environment: IRuntimeEnvironment,
}> = [
{

View File

@@ -17,6 +17,7 @@ describe('DependencyProvider', () => {
useClipboard: createTransientTests(),
useCurrentCode: createTransientTests(),
useUserSelectionState: createTransientTests(),
useLogger: createTransientTests(),
};
Object.entries(testCases).forEach(([key, runTests]) => {
const registeredKey = InjectionKeys[key].key;

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { LoggerFactoryStub } from '@tests/unit/shared/Stubs/LoggerFactoryStub';
describe('UseLogger', () => {
it('returns expected logger from factory', () => {
// arrange
const expectedLogger = new LoggerStub();
const factory = new LoggerFactoryStub()
.withLogger(expectedLogger);
// act
const { log: actualLogger } = useLogger(factory);
// assert
expect(actualLogger).to.equal(expectedLogger);
});
});

View File

@@ -3,7 +3,7 @@ import { provideWindowVariables } from '@/presentation/electron/preload/WindowVa
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
describe('WindowVariablesProvider', () => {
@@ -55,7 +55,7 @@ class TestContext {
private os: OperatingSystem = OperatingSystem.Android;
private log: ILogger = new LoggerStub();
private log: Logger = new LoggerStub();
public withSystem(system: ISystemOperations): this {
this.system = system;
@@ -67,7 +67,7 @@ class TestContext {
return this;
}
public withLogger(log: ILogger): this {
public withLogger(log: Logger): this {
this.log = log;
return this;
}

View File

@@ -0,0 +1,12 @@
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { LoggerStub } from './LoggerStub';
export class LoggerFactoryStub implements LoggerFactory {
public logger: Logger = new LoggerStub();
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
}

View File

@@ -1,12 +1,32 @@
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class LoggerStub extends StubWithObservableMethodCalls<ILogger> implements ILogger {
export class LoggerStub extends StubWithObservableMethodCalls<Logger> implements Logger {
public warn(...params: unknown[]): void {
this.registerMethodCall({
methodName: 'warn',
args: params,
});
}
public error(...params: unknown[]): void {
this.registerMethodCall({
methodName: 'error',
args: params,
});
}
public debug(...params: unknown[]): void {
this.registerMethodCall({
methodName: 'debug',
args: params,
});
}
public info(...params: unknown[]): void {
this.registerMethodCall({
methodName: 'info',
args: params,
});
console.log(...params);
}
}

View File

@@ -1,5 +1,5 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { SystemOperationsStub } from './SystemOperationsStub';
@@ -8,18 +8,18 @@ import { LoggerStub } from './LoggerStub';
export class WindowVariablesStub implements WindowVariables {
public system?: ISystemOperations = new SystemOperationsStub();
public isDesktop? = false;
public isDesktop = false;
public os?: OperatingSystem = OperatingSystem.BlackBerryOS;
public log?: ILogger = new LoggerStub();
public log: Logger = new LoggerStub();
public withLog(log?: ILogger): this {
public withLog(log: Logger): this {
this.log = log;
return this;
}
public withIsDesktop(value?: boolean): this {
public withIsDesktop(value: boolean): this {
this.isDesktop = value;
return this;
}