Add automated checks for desktop app runtime #233
- Add automation script for building, packaging, installing, executing and verifying Electron distrubtions across macOS, Ubuntu and Windows. - Add GitHub workflow to run the script to test distributions using the script. - Update README with new workflow status badge. - Add application initialization log to desktop applications to be able to test against crashes before application initialization.
This commit is contained in:
48
scripts/check-desktop-runtime-errors/utils/io.js
Normal file
48
scripts/check-desktop-runtime-errors/utils/io.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { extname, join } from 'path';
|
||||
import { readdir, access } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { log, die, LOG_LEVELS } from './log.js';
|
||||
|
||||
export async function findSingleFileByExtension(extension, directory) {
|
||||
if (!directory) { throw new Error('Missing directory'); }
|
||||
if (!extension) { throw new Error('Missing file extension'); }
|
||||
|
||||
if (!await exists(directory)) {
|
||||
die(`Directory does not exist: ${directory}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const directoryContents = await readdir(directory);
|
||||
const foundFileNames = directoryContents.filter((file) => extname(file) === `.${extension}`);
|
||||
const withoutUninstaller = foundFileNames.filter(
|
||||
(fileName) => !fileName.toLowerCase().includes('uninstall'), // NSIS build has `Uninstall {app-name}.exe`
|
||||
);
|
||||
if (!withoutUninstaller.length) {
|
||||
die(`No ${extension} found in ${directory} directory.`);
|
||||
}
|
||||
if (withoutUninstaller.length > 1) {
|
||||
log(`Found multiple ${extension} files: ${withoutUninstaller.join(', ')}. Using first occurrence`, LOG_LEVELS.WARN);
|
||||
}
|
||||
return {
|
||||
absolutePath: join(directory, withoutUninstaller[0]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function exists(path) {
|
||||
if (!path) { throw new Error('Missing path'); }
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDirMissingOrEmpty(dir) {
|
||||
if (!dir) { throw new Error('Missing directory'); }
|
||||
if (!await exists(dir)) {
|
||||
return true;
|
||||
}
|
||||
const contents = await readdir(dir);
|
||||
return contents.length === 0;
|
||||
}
|
||||
39
scripts/check-desktop-runtime-errors/utils/log.js
Normal file
39
scripts/check-desktop-runtime-errors/utils/log.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export const LOG_LEVELS = Object.freeze({
|
||||
INFO: 'INFO',
|
||||
WARN: 'WARN',
|
||||
ERROR: 'ERROR',
|
||||
});
|
||||
|
||||
export function log(message, level = LOG_LEVELS.INFO) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const config = LOG_LEVEL_CONFIG[level] || LOG_LEVEL_CONFIG[LOG_LEVELS.INFO];
|
||||
const formattedMessage = `[${timestamp}][${config.color}${level}${COLOR_CODES.RESET}] ${message}`;
|
||||
config.method(formattedMessage);
|
||||
}
|
||||
|
||||
export function die(message) {
|
||||
log(message, LOG_LEVELS.ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const COLOR_CODES = {
|
||||
RESET: '\x1b[0m',
|
||||
LIGHT_RED: '\x1b[91m',
|
||||
YELLOW: '\x1b[33m',
|
||||
LIGHT_BLUE: '\x1b[94m',
|
||||
};
|
||||
|
||||
const LOG_LEVEL_CONFIG = {
|
||||
[LOG_LEVELS.INFO]: {
|
||||
color: COLOR_CODES.LIGHT_BLUE,
|
||||
method: console.log,
|
||||
},
|
||||
[LOG_LEVELS.WARN]: {
|
||||
color: COLOR_CODES.YELLOW,
|
||||
method: console.warn,
|
||||
},
|
||||
[LOG_LEVELS.ERROR]: {
|
||||
color: COLOR_CODES.LIGHT_RED,
|
||||
method: console.error,
|
||||
},
|
||||
};
|
||||
87
scripts/check-desktop-runtime-errors/utils/npm.js
Normal file
87
scripts/check-desktop-runtime-errors/utils/npm.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { join } from 'path';
|
||||
import { rmdir, readFile } from 'fs/promises';
|
||||
import { exists, isDirMissingOrEmpty } from './io.js';
|
||||
import { runCommand } from './run-command.js';
|
||||
import { LOG_LEVELS, die, log } from './log.js';
|
||||
|
||||
export async function ensureNpmProjectDir(projectDir) {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
if (!await exists(join(projectDir, 'package.json'))) {
|
||||
die(`'package.json' not found in project directory: ${projectDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function npmInstall(projectDir) {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
const npmModulesPath = join(projectDir, 'node_modules');
|
||||
if (!await isDirMissingOrEmpty(npmModulesPath)) {
|
||||
log(`Directory "${npmModulesPath}" exists and has content. Skipping 'npm install'.`);
|
||||
return;
|
||||
}
|
||||
log('Starting dependency installation...');
|
||||
const { error } = await runCommand('npm install --loglevel=error', {
|
||||
stdio: 'inherit',
|
||||
cwd: projectDir,
|
||||
});
|
||||
if (error) {
|
||||
die(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function npmBuild(projectDir, buildCommand, distDir, forceRebuild) {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
if (!buildCommand) { throw new Error('missing build command'); }
|
||||
if (!distDir) { throw new Error('missing distribution directory'); }
|
||||
|
||||
const isMissingBuild = await isDirMissingOrEmpty(distDir);
|
||||
|
||||
if (!isMissingBuild && !forceRebuild) {
|
||||
log(`Directory "${distDir}" exists and has content. Skipping build: '${buildCommand}'.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceRebuild) {
|
||||
log(`Removing directory "${distDir}" for a clean build (triggered by --build flag).`);
|
||||
await rmdir(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
log('Starting project build...');
|
||||
const { error } = await runCommand(buildCommand, {
|
||||
stdio: 'inherit',
|
||||
cwd: projectDir,
|
||||
});
|
||||
if (error) {
|
||||
log(error, LOG_LEVELS.WARN); // Cannot disable Vue CLI errors, stderr contains false-positives.
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppName(projectDir) {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
const packageData = await readPackageJsonContents(projectDir);
|
||||
try {
|
||||
const packageJson = JSON.parse(packageData);
|
||||
if (!packageJson.name) {
|
||||
die(`The 'package.json' file doesn't specify a name: ${packageData}`);
|
||||
}
|
||||
return packageJson.name;
|
||||
} catch (error) {
|
||||
die(`Unable to parse 'package.json'. Error: ${error}\nContent: ${packageData}`, LOG_LEVELS.ERROR);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPackageJsonContents(projectDir) {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
const packagePath = join(projectDir, 'package.json');
|
||||
if (!await exists(packagePath)) {
|
||||
die(`'package.json' file not found at ${packagePath}`);
|
||||
}
|
||||
try {
|
||||
const packageData = await readFile(packagePath, 'utf8');
|
||||
return packageData;
|
||||
} catch (error) {
|
||||
log(`Error reading 'package.json' from ${packagePath}.`, LOG_LEVELS.ERROR);
|
||||
die(`Error detail: ${error}`, LOG_LEVELS.ERROR);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
9
scripts/check-desktop-runtime-errors/utils/platform.js
Normal file
9
scripts/check-desktop-runtime-errors/utils/platform.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { platform } from 'os';
|
||||
|
||||
export const SUPPORTED_PLATFORMS = {
|
||||
MAC: 'darwin',
|
||||
LINUX: 'linux',
|
||||
WINDOWS: 'win32',
|
||||
};
|
||||
|
||||
export const CURRENT_PLATFORM = platform();
|
||||
45
scripts/check-desktop-runtime-errors/utils/run-command.js
Normal file
45
scripts/check-desktop-runtime-errors/utils/run-command.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { exec } from 'child_process';
|
||||
import { LOG_LEVELS, log } from './log.js';
|
||||
import { indentText } from './text.js';
|
||||
|
||||
const TIMEOUT_IN_SECONDS = 180;
|
||||
const MAX_OUTPUT_BUFFER_SIZE = 1024 * 1024; // 1 MB
|
||||
|
||||
export function runCommand(commandString, options) {
|
||||
return new Promise((resolve) => {
|
||||
options = {
|
||||
cwd: process.cwd(),
|
||||
timeout: TIMEOUT_IN_SECONDS * 1000,
|
||||
maxBuffer: MAX_OUTPUT_BUFFER_SIZE * 2,
|
||||
...options,
|
||||
};
|
||||
|
||||
exec(commandString, options, (error, stdout, stderr) => {
|
||||
let errorText;
|
||||
if (error || stderr?.length > 0) {
|
||||
errorText = formatError(commandString, error, stdout, stderr);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
error: errorText,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatError(commandString, error, stdout, stderr) {
|
||||
const errorParts = [
|
||||
'Error while running command.',
|
||||
`Command:\n${indentText(commandString, 1)}`,
|
||||
];
|
||||
if (error?.toString().trim()) {
|
||||
errorParts.push(`Error:\n${indentText(error.toString(), 1)}`);
|
||||
}
|
||||
if (stderr?.toString().trim()) {
|
||||
errorParts.push(`stderr:\n${indentText(stderr, 1)}`);
|
||||
}
|
||||
if (stdout?.toString().trim()) {
|
||||
errorParts.push(`stdout:\n${indentText(stdout, 1)}`);
|
||||
}
|
||||
return errorParts.join('\n---\n');
|
||||
}
|
||||
19
scripts/check-desktop-runtime-errors/utils/text.js
Normal file
19
scripts/check-desktop-runtime-errors/utils/text.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export function indentText(text, indentLevel = 1) {
|
||||
validateText(text);
|
||||
const indentation = '\t'.repeat(indentLevel);
|
||||
return splitTextIntoLines(text)
|
||||
.map((line) => (line ? `${indentation}${line}` : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function splitTextIntoLines(text) {
|
||||
validateText(text);
|
||||
return text
|
||||
.split(/[\r\n]+/);
|
||||
}
|
||||
|
||||
function validateText(text) {
|
||||
if (typeof text !== 'string') {
|
||||
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user