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:
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
68
scripts/check-desktop-runtime-errors/utils/log.ts
Normal file
68
scripts/check-desktop-runtime-errors/utils/log.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
120
scripts/check-desktop-runtime-errors/utils/npm.ts
Normal file
120
scripts/check-desktop-runtime-errors/utils/npm.ts
Normal 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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { platform } from 'os';
|
||||
|
||||
export const SUPPORTED_PLATFORMS = {
|
||||
MAC: 'darwin',
|
||||
LINUX: 'linux',
|
||||
WINDOWS: 'win32',
|
||||
};
|
||||
|
||||
export const CURRENT_PLATFORM = platform();
|
||||
31
scripts/check-desktop-runtime-errors/utils/platform.ts
Normal file
31
scripts/check-desktop-runtime-errors/utils/platform.ts
Normal 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();
|
||||
@@ -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');
|
||||
5
scripts/check-desktop-runtime-errors/utils/sleep.ts
Normal file
5
scripts/check-desktop-runtime-errors/utils/sleep.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function sleep(milliseconds: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds);
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user