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:
@@ -21,7 +21,7 @@ module.exports = {
|
|||||||
'@vue/typescript/recommended',
|
'@vue/typescript/recommended',
|
||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 12, // ECMA 2021
|
ecmaVersion: 2022, // So it allows top-level awaits
|
||||||
/*
|
/*
|
||||||
Having 'latest' leads to:
|
Having 'latest' leads to:
|
||||||
```
|
```
|
||||||
|
|||||||
67
.github/workflows/checks.desktop-runtime-errors.yaml
vendored
Normal file
67
.github/workflows/checks.desktop-runtime-errors.yaml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: checks.desktop-runtime-errors
|
||||||
|
# Verifies desktop builds for Electron applications across multiple OS platforms (macOS ,Ubuntu, and Windows).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-desktop:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ macos, ubuntu, windows ]
|
||||||
|
fail-fast: false # Allows to see results from other combinations
|
||||||
|
runs-on: ${{ matrix.os }}-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
-
|
||||||
|
name: Setup node
|
||||||
|
uses: ./.github/actions/setup-node
|
||||||
|
-
|
||||||
|
name: Configure Ubuntu
|
||||||
|
if: matrix.os == 'ubuntu'
|
||||||
|
shell: bash
|
||||||
|
run: |-
|
||||||
|
sudo apt update
|
||||||
|
|
||||||
|
# Configure AppImage dependencies
|
||||||
|
sudo apt install -y libfuse2
|
||||||
|
|
||||||
|
# Configure DBUS (fixes `Failed to connect to the bus: Could not parse server address: Unknown address type`)
|
||||||
|
if ! command -v 'dbus-launch' &> /dev/null; then
|
||||||
|
echo 'DBUS does not exist, installing...'
|
||||||
|
sudo apt install -y dbus-x11 # Gives both dbus and dbus-launch utility
|
||||||
|
fi
|
||||||
|
sudo systemctl start dbus
|
||||||
|
DBUS_LAUNCH_OUTPUT=$(dbus-launch)
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "${DBUS_LAUNCH_OUTPUT}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo 'Error: dbus-launch command did not execute successfully. Exiting.' >&2
|
||||||
|
echo "${DBUS_LAUNCH_OUTPUT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure fake (virtual) display
|
||||||
|
sudo apt install -y xvfb
|
||||||
|
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||||
|
echo "DISPLAY=:99" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Install ImageMagick for screenshots
|
||||||
|
sudo apt install -y imagemagick
|
||||||
|
|
||||||
|
# Install xdotool and xprop (from x11-utils) for window title capturing
|
||||||
|
sudo apt install -y xdotool x11-utils
|
||||||
|
-
|
||||||
|
name: Test
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/check-desktop-runtime-errors --screenshot
|
||||||
|
-
|
||||||
|
name: Upload screenshot
|
||||||
|
if: always() # Run even if previous step fails
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: screenshot-${{ matrix.os }}
|
||||||
|
path: screenshot.png
|
||||||
@@ -70,6 +70,12 @@
|
|||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.desktop-runtime-errors.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
alt="Status of runtime error checks for the desktop application"
|
||||||
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.desktop-runtime-errors/badge.svg"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
<!-- Release -->
|
<!-- Release -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -6,7 +6,7 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "privacy.sexy",
|
"name": "privacy.sexy",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
|
|||||||
11
scripts/check-desktop-runtime-errors/.eslintrc.cjs
Normal file
11
scripts/check-desktop-runtime-errors/.eslintrc.cjs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const { rules: baseStyleRules } = require('eslint-config-airbnb-base/rules/style');
|
||||||
|
require('@rushstack/eslint-patch/modern-module-resolution');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"import/extensions": ["error", "always"],
|
||||||
|
},
|
||||||
|
};
|
||||||
35
scripts/check-desktop-runtime-errors/README.md
Normal file
35
scripts/check-desktop-runtime-errors/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# check-desktop-runtime-errors
|
||||||
|
|
||||||
|
This script automates the processes of:
|
||||||
|
|
||||||
|
1) Building
|
||||||
|
2) Packaging
|
||||||
|
3) Installing
|
||||||
|
4) Executing
|
||||||
|
5) Verifying Electron distributions
|
||||||
|
|
||||||
|
It runs the application for a duration and detects runtime errors in the packaged application via:
|
||||||
|
|
||||||
|
- **Log verification**: Checking application logs for errors and validating successful application initialization.
|
||||||
|
- **`stderr` monitoring**: Continuous listening to the `stderr` stream for unexpected errors.
|
||||||
|
- **Window title inspection**: Checking for window titles that indicate crashes before logging becomes possible.
|
||||||
|
|
||||||
|
Upon error, the script captures a screenshot (if `--screenshot` is provided) and terminates.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node ./scripts/check-desktop-runtime-errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `--build`: Clears the electron distribution directory and forces a rebuild of the Electron app.
|
||||||
|
- `--screenshot`: Takes a screenshot of the desktop environment after running the application.
|
||||||
|
|
||||||
|
This module provides utilities for building, executing, and validating Electron desktop apps.
|
||||||
|
It can be used to automate checking for runtime errors during development.
|
||||||
|
|
||||||
|
## Configs
|
||||||
|
|
||||||
|
Configurations are defined in [`config.js`](./config.js).
|
||||||
55
scripts/check-desktop-runtime-errors/app/app-logs.js
Normal file
55
scripts/check-desktop-runtime-errors/app/app-logs.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { unlink, readFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { log, die, LOG_LEVELS } from '../utils/log.js';
|
||||||
|
import { exists } from '../utils/io.js';
|
||||||
|
import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../utils/platform.js';
|
||||||
|
import { getAppName } from '../utils/npm.js';
|
||||||
|
|
||||||
|
export async function clearAppLogFile(projectDir) {
|
||||||
|
if (!projectDir) { throw new Error('missing project directory'); }
|
||||||
|
const logPath = await determineLogPath(projectDir);
|
||||||
|
if (!logPath || !await exists(logPath)) {
|
||||||
|
log(`Skipping clearing logs, log file does not exist: ${logPath}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await unlink(logPath);
|
||||||
|
log(`Successfully cleared the log file at: ${logPath}.`);
|
||||||
|
} catch (error) {
|
||||||
|
die(`Failed to clear the log file at: ${logPath}. Reason: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAppLogFile(projectDir) {
|
||||||
|
if (!projectDir) { throw new Error('missing project directory'); }
|
||||||
|
const logPath = await determineLogPath(projectDir);
|
||||||
|
if (!logPath || !await exists(logPath)) {
|
||||||
|
log(`No log file at: ${logPath}`, LOG_LEVELS.WARN);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const logContent = await readLogFile(logPath);
|
||||||
|
return logContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function determineLogPath(projectDir) {
|
||||||
|
if (!projectDir) { throw new Error('missing project directory'); }
|
||||||
|
const appName = await getAppName(projectDir);
|
||||||
|
if (!appName) {
|
||||||
|
die('App name not found.');
|
||||||
|
}
|
||||||
|
const logFilePaths = {
|
||||||
|
[SUPPORTED_PLATFORMS.MAC]: () => join(process.env.HOME, 'Library', 'Logs', appName, 'main.log'),
|
||||||
|
[SUPPORTED_PLATFORMS.LINUX]: () => join(process.env.HOME, '.config', appName, 'logs', 'main.log'),
|
||||||
|
[SUPPORTED_PLATFORMS.WINDOWS]: () => join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', 'main.log'),
|
||||||
|
};
|
||||||
|
const logFilePath = logFilePaths[CURRENT_PLATFORM]?.();
|
||||||
|
if (!logFilePath) {
|
||||||
|
log(`Cannot determine log path, unsupported OS: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
|
||||||
|
}
|
||||||
|
return logFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLogFile(logFilePath) {
|
||||||
|
const content = await readFile(logFilePath, 'utf-8');
|
||||||
|
return content?.trim().length > 0 ? content : undefined;
|
||||||
|
}
|
||||||
114
scripts/check-desktop-runtime-errors/app/check-for-errors.js
Normal file
114
scripts/check-desktop-runtime-errors/app/check-for-errors.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { splitTextIntoLines, indentText } from '../utils/text.js';
|
||||||
|
import { die } from '../utils/log.js';
|
||||||
|
import { readAppLogFile } from './app-logs.js';
|
||||||
|
|
||||||
|
const LOG_ERROR_MARKER = '[error]'; // from electron-log
|
||||||
|
const ELECTRON_CRASH_TITLE = 'Error'; // Used by electron for early crashes
|
||||||
|
const APP_INITIALIZED_MARKER = '[APP_INIT_SUCCESS]'; // Logged by application on successful initialization
|
||||||
|
|
||||||
|
export async function checkForErrors(stderr, windowTitles, projectDir) {
|
||||||
|
if (!projectDir) { throw new Error('missing project directory'); }
|
||||||
|
const errors = await gatherErrors(stderr, windowTitles, projectDir);
|
||||||
|
if (errors.length) {
|
||||||
|
die(formatErrors(errors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gatherErrors(stderr, windowTitles, projectDir) {
|
||||||
|
if (!projectDir) { throw new Error('missing project directory'); }
|
||||||
|
const logContent = await readAppLogFile(projectDir);
|
||||||
|
return [
|
||||||
|
verifyStdErr(stderr),
|
||||||
|
verifyApplicationInitializationLog(logContent),
|
||||||
|
verifyWindowTitle(windowTitles),
|
||||||
|
verifyErrorsInLogs(logContent),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrors(errors) {
|
||||||
|
if (!errors || !errors.length) { throw new Error('missing errors'); }
|
||||||
|
return [
|
||||||
|
'Errors detected during execution:',
|
||||||
|
...errors.map(
|
||||||
|
(error) => formatError(error),
|
||||||
|
),
|
||||||
|
].join('\n---\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(error) {
|
||||||
|
if (!error) { throw new Error('missing error'); }
|
||||||
|
if (!error.reason) { throw new Error(`missing reason, error (${typeof error}): ${JSON.stringify(error)}`); }
|
||||||
|
let message = `Reason: ${indentText(error.reason, 1)}`;
|
||||||
|
if (error.description) {
|
||||||
|
message += `\nDescription:\n${indentText(error.description, 2)}`;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyApplicationInitializationLog(logContent) {
|
||||||
|
if (!logContent || !logContent.length) {
|
||||||
|
return describeError(
|
||||||
|
'Missing application logs',
|
||||||
|
'Application logs are empty not were not found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!logContent.includes(APP_INITIALIZED_MARKER)) {
|
||||||
|
return describeError(
|
||||||
|
'Unexpected application logs',
|
||||||
|
`Missing identifier "${APP_INITIALIZED_MARKER}" in application logs.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyWindowTitle(windowTitles) {
|
||||||
|
const errorTitles = windowTitles.filter(
|
||||||
|
(title) => title.toLowerCase().includes(ELECTRON_CRASH_TITLE),
|
||||||
|
);
|
||||||
|
if (errorTitles.length) {
|
||||||
|
return describeError(
|
||||||
|
'Unexpected window title',
|
||||||
|
'One or more window titles suggest an error occurred in the application:'
|
||||||
|
+ `\nError Titles: ${errorTitles.join(', ')}`
|
||||||
|
+ `\nAll Titles: ${windowTitles.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyStdErr(stderrOutput) {
|
||||||
|
if (stderrOutput && stderrOutput.length > 0) {
|
||||||
|
return describeError(
|
||||||
|
'Standard error stream (`stderr`) is not empty.',
|
||||||
|
stderrOutput,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyErrorsInLogs(logContent) {
|
||||||
|
if (!logContent || !logContent.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const logLines = getNonEmptyLines(logContent)
|
||||||
|
.filter((line) => line.includes(LOG_ERROR_MARKER));
|
||||||
|
if (!logLines.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return describeError(
|
||||||
|
'Application log file',
|
||||||
|
logLines.join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(reason, description) {
|
||||||
|
return {
|
||||||
|
reason,
|
||||||
|
description: `${description}\nThis might indicate an early crash or significant runtime issue.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNonEmptyLines(text) {
|
||||||
|
return splitTextIntoLines(text)
|
||||||
|
.filter((line) => line?.trim().length > 0);
|
||||||
|
}
|
||||||
34
scripts/check-desktop-runtime-errors/app/extractors/linux.js
Normal file
34
scripts/check-desktop-runtime-errors/app/extractors/linux.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { access, chmod } from 'fs/promises';
|
||||||
|
import { constants } from 'fs';
|
||||||
|
import { findSingleFileByExtension } from '../../utils/io.js';
|
||||||
|
import { log } from '../../utils/log.js';
|
||||||
|
|
||||||
|
export async function prepareLinuxApp(desktopDistPath) {
|
||||||
|
const { absolutePath: appFile } = await findSingleFileByExtension(
|
||||||
|
'AppImage',
|
||||||
|
desktopDistPath,
|
||||||
|
);
|
||||||
|
await makeExecutable(appFile);
|
||||||
|
return {
|
||||||
|
appExecutablePath: appFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeExecutable(appFile) {
|
||||||
|
if (!appFile) { throw new Error('missing file'); }
|
||||||
|
if (await isExecutable(appFile)) {
|
||||||
|
log('AppImage is already executable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log('Making it executable...');
|
||||||
|
await chmod(appFile, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isExecutable(file) {
|
||||||
|
try {
|
||||||
|
await access(file, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
scripts/check-desktop-runtime-errors/app/extractors/macos.js
Normal file
66
scripts/check-desktop-runtime-errors/app/extractors/macos.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { runCommand } from '../../utils/run-command.js';
|
||||||
|
import { findSingleFileByExtension, exists } from '../../utils/io.js';
|
||||||
|
import { log, die, LOG_LEVELS } from '../../utils/log.js';
|
||||||
|
|
||||||
|
export async function prepareMacOsApp(desktopDistPath) {
|
||||||
|
const { absolutePath: dmgPath } = await findSingleFileByExtension('dmg', desktopDistPath);
|
||||||
|
const { mountPath } = await mountDmg(dmgPath);
|
||||||
|
const appPath = await findMacAppExecutablePath(mountPath);
|
||||||
|
return {
|
||||||
|
appExecutablePath: appPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
log('Cleaning up resources...');
|
||||||
|
await detachMount(mountPath);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountDmg(dmgFile) {
|
||||||
|
const { stdout: hdiutilOutput, error } = await runCommand(`hdiutil attach '${dmgFile}'`);
|
||||||
|
if (error) {
|
||||||
|
die(`Failed to mount DMG file at ${dmgFile}.\n${error}`);
|
||||||
|
}
|
||||||
|
const mountPathMatch = hdiutilOutput.match(/\/Volumes\/[^\n]+/);
|
||||||
|
const mountPath = mountPathMatch ? mountPathMatch[0] : null;
|
||||||
|
return {
|
||||||
|
mountPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMacAppExecutablePath(mountPath) {
|
||||||
|
const { stdout: findOutput, error } = await runCommand(
|
||||||
|
`find '${mountPath}' -maxdepth 1 -type d -name "*.app"`,
|
||||||
|
);
|
||||||
|
if (error) {
|
||||||
|
die(`Failed to find executable path at mount path ${mountPath}\n${error}`);
|
||||||
|
}
|
||||||
|
const appFolder = findOutput.trim();
|
||||||
|
const appName = appFolder.split('/').pop().replace('.app', '');
|
||||||
|
const appPath = `${appFolder}/Contents/MacOS/${appName}`;
|
||||||
|
if (await exists(appPath)) {
|
||||||
|
log(`Application is located at ${appPath}`);
|
||||||
|
} else {
|
||||||
|
die(`Application does not exist at ${appPath}`);
|
||||||
|
}
|
||||||
|
return appPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detachMount(mountPath, retries = 5) {
|
||||||
|
const { error } = await runCommand(`hdiutil detach '${mountPath}'`);
|
||||||
|
if (error) {
|
||||||
|
if (retries <= 0) {
|
||||||
|
log(`Failed to detach mount after multiple attempts: ${mountPath}\n${error}`, LOG_LEVELS.WARN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(500);
|
||||||
|
await detachMount(mountPath, retries - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Successfully detached from ${mountPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(milliseconds) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, milliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { mkdtemp, rmdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { findSingleFileByExtension, exists } from '../../utils/io.js';
|
||||||
|
import { log, die } from '../../utils/log.js';
|
||||||
|
import { runCommand } from '../../utils/run-command.js';
|
||||||
|
|
||||||
|
export async function prepareWindowsApp(desktopDistPath) {
|
||||||
|
const workdir = await mkdtemp(join(tmpdir(), 'win-nsis-installation-'));
|
||||||
|
if (await exists(workdir)) {
|
||||||
|
log(`Temporary directory ${workdir} already exists, cleaning up...`);
|
||||||
|
await rmdir(workdir, { recursive: true });
|
||||||
|
}
|
||||||
|
const { appExecutablePath } = await installNsis(workdir, desktopDistPath);
|
||||||
|
return {
|
||||||
|
appExecutablePath,
|
||||||
|
cleanup: async () => {
|
||||||
|
log(`Cleaning up working directory ${workdir}...`);
|
||||||
|
await rmdir(workdir, { recursive: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installNsis(installationPath, desktopDistPath) {
|
||||||
|
const { absolutePath: installerPath } = await findSingleFileByExtension('exe', desktopDistPath);
|
||||||
|
|
||||||
|
log(`Silently installing contents of ${installerPath} to ${installationPath}...`);
|
||||||
|
const { error } = await runCommand(`"${installerPath}" /S /D=${installationPath}`);
|
||||||
|
if (error) {
|
||||||
|
die(`Failed to install.\n${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { absolutePath: appExecutablePath } = await findSingleFileByExtension('exe', installationPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appExecutablePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
164
scripts/check-desktop-runtime-errors/app/runner.js
Normal file
164
scripts/check-desktop-runtime-errors/app/runner.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { log, LOG_LEVELS, die } from '../utils/log.js';
|
||||||
|
import { captureScreen } from './system-capture/screen-capture.js';
|
||||||
|
import { captureWindowTitles } from './system-capture/window-title-capture.js';
|
||||||
|
|
||||||
|
const TERMINATION_GRACE_PERIOD_IN_SECONDS = 60;
|
||||||
|
const TERMINATION_CHECK_INTERVAL_IN_MS = 1000;
|
||||||
|
const WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS = 100;
|
||||||
|
|
||||||
|
export function runApplication(
|
||||||
|
appFile,
|
||||||
|
executionDurationInSeconds,
|
||||||
|
enableScreenshot,
|
||||||
|
screenshotPath,
|
||||||
|
) {
|
||||||
|
if (!appFile) {
|
||||||
|
throw new Error('Missing app file');
|
||||||
|
}
|
||||||
|
|
||||||
|
logDetails(appFile, executionDurationInSeconds);
|
||||||
|
|
||||||
|
const processDetails = {
|
||||||
|
stderrData: '',
|
||||||
|
stdoutData: '',
|
||||||
|
explicitlyKilled: false,
|
||||||
|
windowTitles: [],
|
||||||
|
isCrashed: false,
|
||||||
|
isDone: false,
|
||||||
|
process: undefined,
|
||||||
|
resolve: () => { /* NOOP */ },
|
||||||
|
};
|
||||||
|
|
||||||
|
const process = spawn(appFile);
|
||||||
|
processDetails.process = process;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
processDetails.resolve = resolve;
|
||||||
|
handleTitleCapture(process.pid, processDetails);
|
||||||
|
handleProcessEvents(
|
||||||
|
processDetails,
|
||||||
|
enableScreenshot,
|
||||||
|
screenshotPath,
|
||||||
|
executionDurationInSeconds,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDetails(appFile, executionDurationInSeconds) {
|
||||||
|
log(
|
||||||
|
[
|
||||||
|
'Executing the app to check for errors...',
|
||||||
|
`Maximum execution time: ${executionDurationInSeconds}`,
|
||||||
|
`Application path: ${appFile}`,
|
||||||
|
].join('\n\t'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleCapture(processId, processDetails) {
|
||||||
|
const capture = async () => {
|
||||||
|
const titles = await captureWindowTitles(processId);
|
||||||
|
|
||||||
|
(titles || []).forEach((title) => {
|
||||||
|
if (!title || !title.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!processDetails.windowTitles.includes(title)) {
|
||||||
|
log(`New window title captured: ${title}`);
|
||||||
|
processDetails.windowTitles.push(title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!processDetails.isDone) {
|
||||||
|
setTimeout(capture, WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
capture();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProcessEvents(
|
||||||
|
processDetails,
|
||||||
|
enableScreenshot,
|
||||||
|
screenshotPath,
|
||||||
|
executionDurationInSeconds,
|
||||||
|
) {
|
||||||
|
const { process } = processDetails;
|
||||||
|
process.stderr.on('data', (data) => {
|
||||||
|
processDetails.stderrData += data.toString();
|
||||||
|
});
|
||||||
|
process.stdout.on('data', (data) => {
|
||||||
|
processDetails.stdoutData += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', (error) => {
|
||||||
|
die(`An issue spawning the child process: ${error}`, LOG_LEVELS.ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('exit', async (code) => {
|
||||||
|
await onProcessExit(code, processDetails, enableScreenshot, screenshotPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath);
|
||||||
|
}, executionDurationInSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onProcessExit(code, processDetails, enableScreenshot, screenshotPath) {
|
||||||
|
log(`Application exited ${code === null || Number.isNaN(code) ? '.' : `with code ${code}`}`);
|
||||||
|
|
||||||
|
if (processDetails.explicitlyKilled) return;
|
||||||
|
|
||||||
|
processDetails.isCrashed = true;
|
||||||
|
|
||||||
|
if (enableScreenshot) {
|
||||||
|
await captureScreen(screenshotPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishProcess(processDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath) {
|
||||||
|
if (enableScreenshot) {
|
||||||
|
await captureScreen(screenshotPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
processDetails.explicitlyKilled = true;
|
||||||
|
await terminateGracefully(process);
|
||||||
|
finishProcess(processDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishProcess(processDetails) {
|
||||||
|
processDetails.isDone = true;
|
||||||
|
processDetails.resolve({
|
||||||
|
stderr: processDetails.stderrData,
|
||||||
|
stdout: processDetails.stdoutData,
|
||||||
|
windowTitles: [...processDetails.windowTitles],
|
||||||
|
isCrashed: processDetails.isCrashed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateGracefully(process) {
|
||||||
|
let elapsedSeconds = 0;
|
||||||
|
log('Attempting to terminate the process gracefully...');
|
||||||
|
process.kill('SIGTERM');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
elapsedSeconds += TERMINATION_CHECK_INTERVAL_IN_MS / 1000;
|
||||||
|
|
||||||
|
if (!process.killed) {
|
||||||
|
if (elapsedSeconds >= TERMINATION_GRACE_PERIOD_IN_SECONDS) {
|
||||||
|
process.kill('SIGKILL');
|
||||||
|
log('Process did not terminate gracefully within the grace period. Forcing termination.', LOG_LEVELS.WARN);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('Process terminated gracefully.');
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, TERMINATION_CHECK_INTERVAL_IN_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { unlink } from 'fs/promises';
|
||||||
|
import { runCommand } from '../../utils/run-command.js';
|
||||||
|
import { log, LOG_LEVELS } from '../../utils/log.js';
|
||||||
|
import { CURRENT_PLATFORM, SUPPORTED_PLATFORMS } from '../../utils/platform.js';
|
||||||
|
import { exists } from '../../utils/io.js';
|
||||||
|
|
||||||
|
export async function captureScreen(imagePath) {
|
||||||
|
if (!imagePath) {
|
||||||
|
throw new Error('Path for screenshot not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await exists(imagePath)) {
|
||||||
|
log(`Screenshot file already exists at ${imagePath}. It will be overwritten.`, LOG_LEVELS.WARN);
|
||||||
|
unlink(imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformCommands = {
|
||||||
|
[SUPPORTED_PLATFORMS.MAC]: `screencapture -x ${imagePath}`,
|
||||||
|
[SUPPORTED_PLATFORMS.LINUX]: `import -window root ${imagePath}`,
|
||||||
|
[SUPPORTED_PLATFORMS.WINDOWS]: `powershell -NoProfile -EncodedCommand ${encodeForPowershell(getScreenshotPowershellScript(imagePath))}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandForPlatform = platformCommands[CURRENT_PLATFORM];
|
||||||
|
|
||||||
|
if (!commandForPlatform) {
|
||||||
|
log(`Screenshot capture not supported on: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Capturing screenshot to ${imagePath} using command:\n\t> ${commandForPlatform}`);
|
||||||
|
|
||||||
|
const { error } = await runCommand(commandForPlatform);
|
||||||
|
if (error) {
|
||||||
|
log(`Failed to capture screenshot.\n${error}`, LOG_LEVELS.WARN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Captured screenshot to ${imagePath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScreenshotPowershellScript(imagePath) {
|
||||||
|
return `
|
||||||
|
$ProgressPreference = 'SilentlyContinue' # Do not pollute stderr
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||||
|
|
||||||
|
$bmp = New-Object System.Drawing.Bitmap $screenBounds.Width, $screenBounds.Height
|
||||||
|
$graphics = [System.Drawing.Graphics]::FromImage($bmp)
|
||||||
|
$graphics.CopyFromScreen([System.Drawing.Point]::Empty, [System.Drawing.Point]::Empty, $screenBounds.Size)
|
||||||
|
|
||||||
|
$bmp.Save('${imagePath}')
|
||||||
|
$graphics.Dispose()
|
||||||
|
$bmp.Dispose()
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeForPowershell(script) {
|
||||||
|
const buffer = Buffer.from(script, 'utf-16le');
|
||||||
|
return buffer.toString('base64');
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { runCommand } from '../../utils/run-command.js';
|
||||||
|
import { log, LOG_LEVELS } from '../../utils/log.js';
|
||||||
|
import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../../utils/platform.js';
|
||||||
|
|
||||||
|
export async function captureWindowTitles(processId) {
|
||||||
|
if (!processId) { throw new Error('Missing process ID.'); }
|
||||||
|
|
||||||
|
const captureFunction = windowTitleCaptureFunctions[CURRENT_PLATFORM];
|
||||||
|
if (!captureFunction) {
|
||||||
|
log(`Cannot capture window title, unsupported OS: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return captureFunction(processId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTitleCaptureFunctions = {
|
||||||
|
[SUPPORTED_PLATFORMS.MAC]: captureTitlesOnMac,
|
||||||
|
[SUPPORTED_PLATFORMS.LINUX]: captureTitlesOnLinux,
|
||||||
|
[SUPPORTED_PLATFORMS.WINDOWS]: captureTitlesOnWindows,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function captureTitlesOnWindows(processId) {
|
||||||
|
if (!processId) { throw new Error('Missing process ID.'); }
|
||||||
|
|
||||||
|
const { stdout: tasklistOutput, error } = await runCommand(
|
||||||
|
`tasklist /FI "PID eq ${processId}" /fo list /v`,
|
||||||
|
);
|
||||||
|
if (error) {
|
||||||
|
log(`Failed to retrieve window title.\n${error}`, LOG_LEVELS.WARN);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const match = tasklistOutput.match(/Window Title:\s*(.*)/);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const title = match[1].trim();
|
||||||
|
if (title === 'N/A') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [title];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureTitlesOnLinux(processId) {
|
||||||
|
if (!processId) { throw new Error('Missing process ID.'); }
|
||||||
|
|
||||||
|
const { stdout: windowIdsOutput, error: windowIdError } = await runCommand(
|
||||||
|
`xdotool search --pid '${processId}'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (windowIdError || !windowIdsOutput) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowIds = windowIdsOutput.trim().split('\n');
|
||||||
|
|
||||||
|
const titles = await Promise.all(windowIds.map(async (windowId) => {
|
||||||
|
const { stdout: titleOutput, error: titleError } = await runCommand(
|
||||||
|
`xprop -id ${windowId} | grep "WM_NAME(STRING)" | cut -d '=' -f 2 | sed 's/^[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/'`
|
||||||
|
);
|
||||||
|
if (titleError || !titleOutput) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return titleOutput.trim();
|
||||||
|
}));
|
||||||
|
|
||||||
|
return titles.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasAssistiveAccessOnMac = true;
|
||||||
|
|
||||||
|
async function captureTitlesOnMac(processId) {
|
||||||
|
if (!processId) { throw new Error('Missing process ID.'); }
|
||||||
|
if (!hasAssistiveAccessOnMac) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const script = `
|
||||||
|
tell application "System Events"
|
||||||
|
try
|
||||||
|
set targetProcess to first process whose unix id is ${processId}
|
||||||
|
on error
|
||||||
|
return
|
||||||
|
end try
|
||||||
|
tell targetProcess
|
||||||
|
if (count of windows) > 0 then
|
||||||
|
set window_name to name of front window
|
||||||
|
return window_name
|
||||||
|
end if
|
||||||
|
end tell
|
||||||
|
end tell
|
||||||
|
`;
|
||||||
|
const argument = script.trim()
|
||||||
|
.split(/[\r\n]+/)
|
||||||
|
.map((line) => `-e '${line.trim()}'`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
|
||||||
|
if (error) {
|
||||||
|
let errorMessage = '';
|
||||||
|
if (error.includes('-25211')) {
|
||||||
|
errorMessage += 'Capturing window title requires assistive access. You do not have it.\n';
|
||||||
|
hasAssistiveAccessOnMac = false;
|
||||||
|
}
|
||||||
|
errorMessage += error;
|
||||||
|
log(errorMessage, LOG_LEVELS.WARN);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const title = titleOutput?.trim();
|
||||||
|
if (!title) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [title];
|
||||||
|
}
|
||||||
20
scripts/check-desktop-runtime-errors/cli-args.js
Normal file
20
scripts/check-desktop-runtime-errors/cli-args.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { log } from './utils/log.js';
|
||||||
|
|
||||||
|
const PROCESS_ARGUMENTS = process.argv.slice(2);
|
||||||
|
|
||||||
|
export const COMMAND_LINE_FLAGS = Object.freeze({
|
||||||
|
FORCE_REBUILD: '--build',
|
||||||
|
TAKE_SCREENSHOT: '--screenshot',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function logCurrentArgs() {
|
||||||
|
if (!PROCESS_ARGUMENTS.length) {
|
||||||
|
log('No additional arguments provided.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Arguments: ${PROCESS_ARGUMENTS.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCommandLineFlag(flag) {
|
||||||
|
return PROCESS_ARGUMENTS.includes(flag);
|
||||||
|
}
|
||||||
7
scripts/check-desktop-runtime-errors/config.js
Normal file
7
scripts/check-desktop-runtime-errors/config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const DESKTOP_BUILD_COMMAND = 'npm run electron:build -- -p never';
|
||||||
|
export const PROJECT_DIR = process.cwd();
|
||||||
|
export const DESKTOP_DIST_PATH = join(PROJECT_DIR, 'dist_electron');
|
||||||
|
export const APP_EXECUTION_DURATION_IN_SECONDS = 60; // Long enough so CI runners have time to bootstrap it
|
||||||
|
export const SCREENSHOT_PATH = join(PROJECT_DIR, 'screenshot.png');
|
||||||
3
scripts/check-desktop-runtime-errors/index.js
Normal file
3
scripts/check-desktop-runtime-errors/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { main } from './main.js';
|
||||||
|
|
||||||
|
await main();
|
||||||
68
scripts/check-desktop-runtime-errors/main.js
Normal file
68
scripts/check-desktop-runtime-errors/main.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { logCurrentArgs, COMMAND_LINE_FLAGS, hasCommandLineFlag } from './cli-args.js';
|
||||||
|
import { log, die } from './utils/log.js';
|
||||||
|
import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm.js';
|
||||||
|
import { clearAppLogFile } from './app/app-logs.js';
|
||||||
|
import { checkForErrors } from './app/check-for-errors.js';
|
||||||
|
import { runApplication } from './app/runner.js';
|
||||||
|
import { CURRENT_PLATFORM, SUPPORTED_PLATFORMS } from './utils/platform.js';
|
||||||
|
import { prepareLinuxApp } from './app/extractors/linux.js';
|
||||||
|
import { prepareWindowsApp } from './app/extractors/windows.js';
|
||||||
|
import { prepareMacOsApp } from './app/extractors/macos.js';
|
||||||
|
import {
|
||||||
|
DESKTOP_BUILD_COMMAND,
|
||||||
|
PROJECT_DIR,
|
||||||
|
DESKTOP_DIST_PATH,
|
||||||
|
APP_EXECUTION_DURATION_IN_SECONDS,
|
||||||
|
SCREENSHOT_PATH,
|
||||||
|
} from './config.js';
|
||||||
|
|
||||||
|
export async function main() {
|
||||||
|
logCurrentArgs();
|
||||||
|
await ensureNpmProjectDir(PROJECT_DIR);
|
||||||
|
await npmInstall(PROJECT_DIR);
|
||||||
|
await npmBuild(
|
||||||
|
PROJECT_DIR,
|
||||||
|
DESKTOP_BUILD_COMMAND,
|
||||||
|
DESKTOP_DIST_PATH,
|
||||||
|
hasCommandLineFlag(COMMAND_LINE_FLAGS.FORCE_REBUILD),
|
||||||
|
);
|
||||||
|
await clearAppLogFile(PROJECT_DIR);
|
||||||
|
const {
|
||||||
|
stderr, stdout, isCrashed, windowTitles,
|
||||||
|
} = await extractAndRun();
|
||||||
|
if (stdout) {
|
||||||
|
log(`Output (stdout) from application execution:\n${stdout}`);
|
||||||
|
}
|
||||||
|
if (isCrashed) {
|
||||||
|
die('The application encountered an error during its execution.');
|
||||||
|
}
|
||||||
|
await checkForErrors(stderr, windowTitles, PROJECT_DIR);
|
||||||
|
log('🥳🎈 Success! Application completed without any runtime errors.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractAndRun() {
|
||||||
|
const extractors = {
|
||||||
|
[SUPPORTED_PLATFORMS.MAC]: () => prepareMacOsApp(DESKTOP_DIST_PATH),
|
||||||
|
[SUPPORTED_PLATFORMS.LINUX]: () => prepareLinuxApp(DESKTOP_DIST_PATH),
|
||||||
|
[SUPPORTED_PLATFORMS.WINDOWS]: () => prepareWindowsApp(DESKTOP_DIST_PATH),
|
||||||
|
};
|
||||||
|
const extractor = extractors[CURRENT_PLATFORM];
|
||||||
|
if (!extractor) {
|
||||||
|
throw new Error(`Platform not supported: ${CURRENT_PLATFORM}`);
|
||||||
|
}
|
||||||
|
const { appExecutablePath, cleanup } = await extractor();
|
||||||
|
try {
|
||||||
|
return await runApplication(
|
||||||
|
appExecutablePath,
|
||||||
|
APP_EXECUTION_DURATION_IN_SECONDS,
|
||||||
|
hasCommandLineFlag(COMMAND_LINE_FLAGS.TAKE_SCREENSHOT),
|
||||||
|
SCREENSHOT_PATH,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (cleanup) {
|
||||||
|
log('Cleaning up post-execution resources...');
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,7 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
{ scheme: 'app', privileges: { secure: true, standard: true } },
|
{ scheme: 'app', privileges: { secure: true, standard: true } },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
log.transports.file.level = 'silly';
|
setupLogger();
|
||||||
if (!process.env.IS_TEST) {
|
|
||||||
Object.assign(console, log.functions); // override console.log, console.warn etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -131,6 +128,9 @@ function loadApplication(window: BrowserWindow) {
|
|||||||
const updater = setupAutoUpdater();
|
const updater = setupAutoUpdater();
|
||||||
updater.checkForUpdates();
|
updater.checkForUpdates();
|
||||||
}
|
}
|
||||||
|
// Do not remove [APP_INIT_SUCCESS]; it's a marker used in tests to verify
|
||||||
|
// app initialization.
|
||||||
|
log.info('[APP_INIT_SUCCESS] Main window initialized and content loading.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
|
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
|
||||||
@@ -155,3 +155,10 @@ function getWindowSize(idealWidth: number, idealHeight: number) {
|
|||||||
height = Math.min(height, idealHeight);
|
height = Math.min(height, idealHeight);
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupLogger(): void {
|
||||||
|
log.transports.file.level = 'silly';
|
||||||
|
if (!process.env.IS_TEST) {
|
||||||
|
Object.assign(console, log.functions); // override console.log, console.warn etc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user