Skip to content

Commit

Permalink
Add tests for desktop app runtime errors $233
Browse files Browse the repository at this point in the history
Add test for runtime errors for desktop app $233
- Add GitHub workflow to verify Electron builds across macOS, Ubuntu, and Windows.
- Update README with new workflow status badge.
- Add automation script for build, execution, and error verification of Electron distributions.
- Add `--disable-gpu` command-line flag to application to disable
  hardware acceleeration to be able to take screenshots during automated
  tests.
- Add application initialization log to desktop applications to be able
  to test against crashes before application initialization.
  • Loading branch information
undergroundwires committed Aug 20, 2023
1 parent 0d15992 commit 2c85c30
Show file tree
Hide file tree
Showing 25 changed files with 1,146 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ module.exports = {
'@vue/typescript/recommended',
],
parserOptions: {
ecmaVersion: 12, // ECMA 2021
ecmaVersion: 2022, // So it allows top-level awaits
/*
Having 'latest' leads to:
```
Expand Down
78 changes: 78 additions & 0 deletions .github/workflows/checks.desktop-runtime-errors.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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: Configure macOS
if: matrix.os == 'macos'
# Disable Gatekeeper as Electron app isn't signed and notarized
run: sudo spctl --master-disable
-
name: Test
shell: bash
run: |-
FLAGS=( "--screenshot" )
if [ "${{ matrix.os }}" == "ubuntu" ]; then
FLAGS+=( "--disable-gpu" )
fi
echo "Using flags: ${FLAGS[@]}"
node scripts/check-desktop-runtime-errors "${FLAGS[@]}"
-
name: Upload screenshot
if: always() # Run even if previous step fails
uses: actions/upload-artifact@v3
with:
name: screenshot-${{ matrix.os }}
path: screenshot.png
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
</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 -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions scripts/check-desktop-runtime-errors/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -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"],
},
};
36 changes: 36 additions & 0 deletions scripts/check-desktop-runtime-errors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 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.
- `--disable-gpu`: Disables hardware acceleration by sending `--disable-gpu` flag to application, especially designed for CI environments.

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 changes: 55 additions & 0 deletions scripts/check-desktop-runtime-errors/app/app-logs.js
Original file line number Diff line number Diff line change
@@ -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 changes: 114 additions & 0 deletions scripts/check-desktop-runtime-errors/app/check-for-errors.js
Original file line number Diff line number Diff line change
@@ -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 changes: 34 additions & 0 deletions scripts/check-desktop-runtime-errors/app/extractors/linux.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 2c85c30

Please sign in to comment.