-
-
Notifications
You must be signed in to change notification settings - Fork 174
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add tests for desktop app runtime errors $233
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
1 parent
0d15992
commit 2c85c30
Showing
25 changed files
with
1,146 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"], | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
114
scripts/check-desktop-runtime-errors/app/check-for-errors.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
34
scripts/check-desktop-runtime-errors/app/extractors/linux.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.