Improve desktop runtime execution tests

Test improvements:

- Capture titles for all macOS windows, not just the frontmost.
- Incorporate missing application log files.
- Improve log clarity with enriched context.
- Improve application termination on macOS by reducing grace period.
- Ensure complete application termination on macOS.
- Validate Vue application loading through an initial log.
- Support ignoring environment-specific `stderr` errors.
- Do not fail the test if working directory cannot be deleted.
- Use retry pattern when installing dependencies due to network errors.

Refactorings:

- Migrate the test code to TypeScript.
- Replace deprecated `rmdir` with `rm` for error-resistant directory
  removal.
- Improve sanity checking by shifting from App.vue to Vue bootstrapper.
- Centralize environment variable management with `EnvironmentVariables`
  construct.
- Rename infrastructure/Environment to RuntimeEnvironment for clarity.
- Isolate WindowVariables and SystemOperations from RuntimeEnvironment.
- Inject logging via preloader.
- Correct mislabeled RuntimeSanity tests.

Configuration:

- Introduce `npm run check:desktop` for simplified execution.
- Omit `console.log` override due to `nodeIntegration` restrictions and
  reveal logging functionality using context-bridging.
This commit is contained in:
undergroundwires
2023-08-29 16:30:00 +02:00
parent 35be05df20
commit ad0576a752
146 changed files with 2418 additions and 1186 deletions

View File

@@ -1,15 +1,17 @@
import { extname, join } from 'path';
import { readdir, access } from 'fs/promises';
import { constants } from 'fs';
import { log, die, LOG_LEVELS } from './log.js';
import { log, die, LogLevel } from './log';
export async function findSingleFileByExtension(extension, directory) {
export async function findSingleFileByExtension(
extension: string,
directory: string,
): Promise<FileSearchResult> {
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 [];
return die(`Directory does not exist: ${directory}`);
}
const directoryContents = await readdir(directory);
@@ -18,17 +20,21 @@ export async function findSingleFileByExtension(extension, directory) {
(fileName) => !fileName.toLowerCase().includes('uninstall'), // NSIS build has `Uninstall {app-name}.exe`
);
if (!withoutUninstaller.length) {
die(`No ${extension} found in ${directory} directory.`);
return die(`No ${extension} found in ${directory} directory.`);
}
if (withoutUninstaller.length > 1) {
log(`Found multiple ${extension} files: ${withoutUninstaller.join(', ')}. Using first occurrence`, LOG_LEVELS.WARN);
log(`Found multiple ${extension} files: ${withoutUninstaller.join(', ')}. Using first occurrence`, LogLevel.Warn);
}
return {
absolutePath: join(directory, withoutUninstaller[0]),
};
}
export async function exists(path) {
interface FileSearchResult {
readonly absolutePath?: string;
}
export async function exists(path: string): Promise<boolean> {
if (!path) { throw new Error('Missing path'); }
try {
await access(path, constants.F_OK);
@@ -38,7 +44,7 @@ export async function exists(path) {
}
}
export async function isDirMissingOrEmpty(dir) {
export async function isDirMissingOrEmpty(dir: string): Promise<boolean> {
if (!dir) { throw new Error('Missing directory'); }
if (!await exists(dir)) {
return true;

View File

@@ -1,39 +0,0 @@
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,
},
};

View File

@@ -0,0 +1,68 @@
export enum LogLevel {
Info,
Warn,
Error,
}
export function log(message: string, level = LogLevel.Info): void {
const timestamp = new Date().toISOString();
const config = LOG_LEVEL_CONFIG[level] || LOG_LEVEL_CONFIG[LogLevel.Info];
const logLevelText = `${getColorCode(config.color)}${LOG_LEVEL_LABELS[level]}${getColorCode(TextColor.Reset)}`;
const formattedMessage = `[${timestamp}][${logLevelText}] ${message}`;
config.method(formattedMessage);
}
export function die(message: string): never {
log(message, LogLevel.Error);
return process.exit(1);
}
enum TextColor {
Reset,
LightRed,
Yellow,
LightBlue,
}
function getColorCode(color: TextColor): string {
return COLOR_CODE_MAPPING[color];
}
const LOG_LEVEL_LABELS: {
readonly [K in LogLevel]: string;
} = {
[LogLevel.Info]: 'INFO',
[LogLevel.Error]: 'ERROR',
[LogLevel.Warn]: 'WARN',
};
const COLOR_CODE_MAPPING: {
readonly [K in TextColor]: string;
} = {
[TextColor.Reset]: '\x1b[0m',
[TextColor.LightRed]: '\x1b[91m',
[TextColor.Yellow]: '\x1b[33m',
[TextColor.LightBlue]: '\x1b[94m',
};
interface ColorLevelConfig {
readonly color: TextColor;
readonly method: (...data: unknown[]) => void;
}
const LOG_LEVEL_CONFIG: {
readonly [K in LogLevel]: ColorLevelConfig;
} = {
[LogLevel.Info]: {
color: TextColor.LightBlue,
method: console.log,
},
[LogLevel.Warn]: {
color: TextColor.Yellow,
method: console.warn,
},
[LogLevel.Error]: {
color: TextColor.LightRed,
method: console.error,
},
};

View File

@@ -1,87 +0,0 @@
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;
}
}

View File

@@ -0,0 +1,120 @@
import { join } from 'path';
import { rm, readFile } from 'fs/promises';
import { exists, isDirMissingOrEmpty } from './io';
import { CommandResult, runCommand } from './run-command';
import { LogLevel, die, log } from './log';
import { sleep } from './sleep';
import type { ExecOptions } from 'child_process';
const NPM_INSTALL_MAX_RETRIES = 3;
const NPM_INSTALL_RETRY_DELAY_MS = 5 /* seconds */ * 1000;
export async function ensureNpmProjectDir(projectDir: string): Promise<void> {
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: string): Promise<void> {
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 executeWithRetry('npm install --loglevel=error', {
cwd: projectDir,
}, NPM_INSTALL_MAX_RETRIES, NPM_INSTALL_RETRY_DELAY_MS);
if (error) {
die(error);
}
log('Installed dependencies...');
}
export async function npmBuild(
projectDir: string,
buildCommand: string,
distDir: string,
forceRebuild: boolean,
): Promise<void> {
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 rm(distDir, { recursive: true, force: true });
}
log('Building project...');
const { error } = await runCommand(buildCommand, {
cwd: projectDir,
});
if (error) {
log(error, LogLevel.Warn); // Cannot disable Vue CLI errors, stderr contains false-positives.
}
}
export async function getAppName(projectDir: string): Promise<string> {
if (!projectDir) { throw new Error('missing project directory'); }
const packageData = await readPackageJsonContents(projectDir);
try {
const packageJson = JSON.parse(packageData);
if (!packageJson.name) {
return die(`The \`package.json\` file doesn't specify a name: ${packageData}`);
}
return packageJson.name;
} catch (error) {
return die(`Unable to parse \`package.json\`. Error: ${error}\nContent: ${packageData}`);
}
}
async function readPackageJsonContents(projectDir: string): Promise<string> {
if (!projectDir) { throw new Error('missing project directory'); }
const packagePath = join(projectDir, 'package.json');
if (!await exists(packagePath)) {
return 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}.`, LogLevel.Error);
return die(`Error detail: ${error}`);
}
}
async function executeWithRetry(
command: string,
options: ExecOptions,
maxRetries: number,
retryDelayInMs: number,
currentAttempt = 1,
): Promise<CommandResult> {
const result = await runCommand(command, options);
if (!result.error || currentAttempt >= maxRetries) {
return result;
}
log(`Attempt ${currentAttempt} failed. Retrying in ${retryDelayInMs / 1000} seconds...`);
await sleep(retryDelayInMs);
const retryResult = await executeWithRetry(
command,
options,
maxRetries,
retryDelayInMs,
currentAttempt + 1,
);
return retryResult;
}

View File

@@ -1,9 +0,0 @@
import { platform } from 'os';
export const SUPPORTED_PLATFORMS = {
MAC: 'darwin',
LINUX: 'linux',
WINDOWS: 'win32',
};
export const CURRENT_PLATFORM = platform();

View File

@@ -0,0 +1,31 @@
import { platform } from 'os';
import { die } from './log';
export enum SupportedPlatform {
macOS,
Windows,
Linux,
}
const NODE_PLATFORM_MAPPINGS: {
readonly [K in SupportedPlatform]: NodeJS.Platform;
} = {
[SupportedPlatform.macOS]: 'darwin',
[SupportedPlatform.Linux]: 'linux',
[SupportedPlatform.Windows]: 'win32',
};
function findCurrentPlatform(): SupportedPlatform | undefined {
const nodePlatform = platform();
for (const key of Object.keys(NODE_PLATFORM_MAPPINGS)) {
const keyAsSupportedPlatform = parseInt(key, 10) as SupportedPlatform;
if (NODE_PLATFORM_MAPPINGS[keyAsSupportedPlatform] === nodePlatform) {
return keyAsSupportedPlatform;
}
}
return die(`Unsupported platform: ${nodePlatform}`);
}
export const CURRENT_PLATFORM: SupportedPlatform = findCurrentPlatform();

View File

@@ -1,22 +1,26 @@
import { exec } from 'child_process';
import { indentText } from './text.js';
import { indentText } from './text';
import type { ExecOptions, ExecException } from 'child_process';
const TIMEOUT_IN_SECONDS = 180;
const MAX_OUTPUT_BUFFER_SIZE = 1024 * 1024; // 1 MB
export function runCommand(commandString, options) {
export function runCommand(
command: string,
options?: ExecOptions,
): Promise<CommandResult> {
return new Promise((resolve) => {
options = {
cwd: process.cwd(),
timeout: TIMEOUT_IN_SECONDS * 1000,
maxBuffer: MAX_OUTPUT_BUFFER_SIZE * 2,
...options,
...(options ?? {}),
};
exec(commandString, options, (error, stdout, stderr) => {
let errorText;
exec(command, options, (error, stdout, stderr) => {
let errorText: string | undefined;
if (error || stderr?.length > 0) {
errorText = formatError(commandString, error, stdout, stderr);
errorText = formatError(command, error, stdout, stderr);
}
resolve({
stdout,
@@ -26,18 +30,28 @@ export function runCommand(commandString, options) {
});
}
function formatError(commandString, error, stdout, stderr) {
export interface CommandResult {
readonly stdout: string;
readonly error?: string;
}
function formatError(
command: string,
error: ExecException | undefined,
stdout: string | undefined,
stderr: string | undefined,
) {
const errorParts = [
'Error while running command.',
`Command:\n${indentText(commandString, 1)}`,
`Command:\n${indentText(command, 1)}`,
];
if (error?.toString().trim()) {
errorParts.push(`Error:\n${indentText(error.toString(), 1)}`);
}
if (stderr?.toString().trim()) {
if (stderr?.trim()) {
errorParts.push(`stderr:\n${indentText(stderr, 1)}`);
}
if (stdout?.toString().trim()) {
if (stdout?.trim()) {
errorParts.push(`stdout:\n${indentText(stdout, 1)}`);
}
return errorParts.join('\n---\n');

View File

@@ -0,0 +1,5 @@
export function sleep(milliseconds: number) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}

View File

@@ -1,4 +1,7 @@
export function indentText(text, indentLevel = 1) {
export function indentText(
text: string,
indentLevel = 1,
): string {
validateText(text);
const indentation = '\t'.repeat(indentLevel);
return splitTextIntoLines(text)
@@ -6,13 +9,13 @@ export function indentText(text, indentLevel = 1) {
.join('\n');
}
export function splitTextIntoLines(text) {
export function splitTextIntoLines(text: string): string[] {
validateText(text);
return text
.split(/[\r\n]+/);
}
function validateText(text) {
function validateText(text: string): void {
if (typeof text !== 'string') {
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
}