From e3e10f876abe0d516e448638a6a4b25189fe7cad Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Sun, 20 Aug 2023 02:43:49 +0100 Subject: [PATCH] Add test for runtime errors for desktop app $233 - Address Electron main process error due to ES Modules. (Related issues: nklayman/vue-cli-plugin-electron-builder$1622, electron/electron$21457, electron/asar$249) - 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. --- .eslintrc.cjs | 2 +- .../checks.desktop-runtime-errors.yaml | 75 ++++++++ README.md | 6 + package-lock.json | 2 +- .../.eslintrc.cjs | 11 ++ .../check-desktop-runtime-errors/README.md | 36 ++++ .../app/app-logs.js | 55 ++++++ .../app/check-for-errors.js | 114 +++++++++++++ .../app/extractors/linux.js | 34 ++++ .../app/extractors/macos.js | 66 ++++++++ .../app/extractors/windows.js | 38 +++++ .../app/runner.js | 160 ++++++++++++++++++ .../app/system-capture/screen-capture.js | 59 +++++++ .../system-capture/window-title-capture.js | 84 +++++++++ .../check-desktop-runtime-errors/cli-args.js | 21 +++ .../check-desktop-runtime-errors/config.js | 7 + scripts/check-desktop-runtime-errors/index.js | 3 + scripts/check-desktop-runtime-errors/main.js | 68 ++++++++ .../check-desktop-runtime-errors/utils/io.js | 48 ++++++ .../check-desktop-runtime-errors/utils/log.js | 39 +++++ .../check-desktop-runtime-errors/utils/npm.js | 87 ++++++++++ .../utils/platform.js | 9 + .../utils/run-command.js | 45 +++++ .../utils/text.js | 19 +++ src/presentation/electron/main.ts | 28 ++- 25 files changed, 1110 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/checks.desktop-runtime-errors.yaml create mode 100644 scripts/check-desktop-runtime-errors/.eslintrc.cjs create mode 100644 scripts/check-desktop-runtime-errors/README.md create mode 100644 scripts/check-desktop-runtime-errors/app/app-logs.js create mode 100644 scripts/check-desktop-runtime-errors/app/check-for-errors.js create mode 100644 scripts/check-desktop-runtime-errors/app/extractors/linux.js create mode 100644 scripts/check-desktop-runtime-errors/app/extractors/macos.js create mode 100644 scripts/check-desktop-runtime-errors/app/extractors/windows.js create mode 100644 scripts/check-desktop-runtime-errors/app/runner.js create mode 100644 scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js create mode 100644 scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js create mode 100644 scripts/check-desktop-runtime-errors/cli-args.js create mode 100644 scripts/check-desktop-runtime-errors/config.js create mode 100644 scripts/check-desktop-runtime-errors/index.js create mode 100644 scripts/check-desktop-runtime-errors/main.js create mode 100644 scripts/check-desktop-runtime-errors/utils/io.js create mode 100644 scripts/check-desktop-runtime-errors/utils/log.js create mode 100644 scripts/check-desktop-runtime-errors/utils/npm.js create mode 100644 scripts/check-desktop-runtime-errors/utils/platform.js create mode 100644 scripts/check-desktop-runtime-errors/utils/run-command.js create mode 100644 scripts/check-desktop-runtime-errors/utils/text.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d90576f9e..125212566 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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: ``` diff --git a/.github/workflows/checks.desktop-runtime-errors.yaml b/.github/workflows/checks.desktop-runtime-errors.yaml new file mode 100644 index 000000000..315a256a0 --- /dev/null +++ b/.github/workflows/checks.desktop-runtime-errors.yaml @@ -0,0 +1,75 @@ +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 + - + 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 diff --git a/README.md b/README.md index 2d6add5f6..7851a0022 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg" /> + + Status of runtime error checks for the desktop application +
diff --git a/package-lock.json b/package-lock.json index 3899c609d..f0cfad239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "privacy.sexy", - "version": "0.12.0", + "version": "0.12.1", "hasInstallScript": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.4.0", diff --git a/scripts/check-desktop-runtime-errors/.eslintrc.cjs b/scripts/check-desktop-runtime-errors/.eslintrc.cjs new file mode 100644 index 000000000..9c0ba7a30 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/.eslintrc.cjs @@ -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"], + }, +}; diff --git a/scripts/check-desktop-runtime-errors/README.md b/scripts/check-desktop-runtime-errors/README.md new file mode 100644 index 000000000..cb36e1481 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/README.md @@ -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). diff --git a/scripts/check-desktop-runtime-errors/app/app-logs.js b/scripts/check-desktop-runtime-errors/app/app-logs.js new file mode 100644 index 000000000..c620a4fa2 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/app-logs.js @@ -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; +} diff --git a/scripts/check-desktop-runtime-errors/app/check-for-errors.js b/scripts/check-desktop-runtime-errors/app/check-for-errors.js new file mode 100644 index 000000000..744e0b19f --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/check-for-errors.js @@ -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); +} diff --git a/scripts/check-desktop-runtime-errors/app/extractors/linux.js b/scripts/check-desktop-runtime-errors/app/extractors/linux.js new file mode 100644 index 000000000..0d4f8e329 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/extractors/linux.js @@ -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; + } +} diff --git a/scripts/check-desktop-runtime-errors/app/extractors/macos.js b/scripts/check-desktop-runtime-errors/app/extractors/macos.js new file mode 100644 index 000000000..4f0730fba --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/extractors/macos.js @@ -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); + }); +} diff --git a/scripts/check-desktop-runtime-errors/app/extractors/windows.js b/scripts/check-desktop-runtime-errors/app/extractors/windows.js new file mode 100644 index 000000000..a03cb947c --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/extractors/windows.js @@ -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, + }; +} diff --git a/scripts/check-desktop-runtime-errors/app/runner.js b/scripts/check-desktop-runtime-errors/app/runner.js new file mode 100644 index 000000000..0ad4b470c --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/runner.js @@ -0,0 +1,160 @@ +import { spawn } from 'child_process'; +import { log, LOG_LEVELS, die } from '../utils/log.js'; +import { captureScreen } from './system-capture/screen-capture.js'; +import { captureWindowTitle } 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 title = await captureWindowTitle(processId); + if (title && title.trim().length > 0) { + 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); + }); +} diff --git a/scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js b/scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js new file mode 100644 index 000000000..fcae87efa --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/system-capture/screen-capture.js @@ -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'); +} diff --git a/scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js b/scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js new file mode 100644 index 000000000..a6ddb29a0 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/app/system-capture/window-title-capture.js @@ -0,0 +1,84 @@ +import { runCommand } from '../../utils/run-command.js'; +import { log, LOG_LEVELS, die } from '../../utils/log.js'; +import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../../utils/platform.js'; + +export async function captureWindowTitle(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 = { + // Future implementations for other OS can be added here. + [SUPPORTED_PLATFORMS.MAC]: captureTitleOnMac, + // [SUPPORTED_PLATFORMS.LINUX]: captureTitleOnLinux, + [SUPPORTED_PLATFORMS.WINDOWS]: captureTitleOnWindows, +}; + +async function captureTitleOnWindows(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 undefined; + } + const match = tasklistOutput.match(/Window Title:\s*(.*)/); + if (match && match[1]) { + const title = match[1].trim(); + if (title === 'N/A') { + return undefined; + } + return title; + } + return undefined; +} + +let hasAssistiveAccessOnMac = true; + +async function captureTitleOnMac(processId) { + if (!processId) { throw new Error('Missing process ID.'); } + if (!hasAssistiveAccessOnMac) { + return undefined; + } + 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: title, 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 undefined; + } + return title?.trim() || undefined; +} diff --git a/scripts/check-desktop-runtime-errors/cli-args.js b/scripts/check-desktop-runtime-errors/cli-args.js new file mode 100644 index 000000000..f588dc61b --- /dev/null +++ b/scripts/check-desktop-runtime-errors/cli-args.js @@ -0,0 +1,21 @@ +import { log } from './utils/log.js'; + +const PROCESS_ARGUMENTS = process.argv.slice(2); + +export const COMMAND_LINE_FLAGS = Object.freeze({ + DISABLE_GPU: '--disable-gpu', + 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); +} diff --git a/scripts/check-desktop-runtime-errors/config.js b/scripts/check-desktop-runtime-errors/config.js new file mode 100644 index 000000000..9597e6d5b --- /dev/null +++ b/scripts/check-desktop-runtime-errors/config.js @@ -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'); diff --git a/scripts/check-desktop-runtime-errors/index.js b/scripts/check-desktop-runtime-errors/index.js new file mode 100644 index 000000000..904a873a4 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/index.js @@ -0,0 +1,3 @@ +import { main } from './main.js'; + +await main(); diff --git a/scripts/check-desktop-runtime-errors/main.js b/scripts/check-desktop-runtime-errors/main.js new file mode 100644 index 000000000..799129631 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/main.js @@ -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(); + } + } +} diff --git a/scripts/check-desktop-runtime-errors/utils/io.js b/scripts/check-desktop-runtime-errors/utils/io.js new file mode 100644 index 000000000..fdebceb9d --- /dev/null +++ b/scripts/check-desktop-runtime-errors/utils/io.js @@ -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; +} diff --git a/scripts/check-desktop-runtime-errors/utils/log.js b/scripts/check-desktop-runtime-errors/utils/log.js new file mode 100644 index 000000000..66b09c1d5 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/utils/log.js @@ -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, + }, +}; diff --git a/scripts/check-desktop-runtime-errors/utils/npm.js b/scripts/check-desktop-runtime-errors/utils/npm.js new file mode 100644 index 000000000..38bc2e048 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/utils/npm.js @@ -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) { + die(error); + } +} + +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; + } +} diff --git a/scripts/check-desktop-runtime-errors/utils/platform.js b/scripts/check-desktop-runtime-errors/utils/platform.js new file mode 100644 index 000000000..24acb1110 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/utils/platform.js @@ -0,0 +1,9 @@ +import { platform } from 'os'; + +export const SUPPORTED_PLATFORMS = { + MAC: 'darwin', + LINUX: 'linux', + WINDOWS: 'win32', +}; + +export const CURRENT_PLATFORM = platform(); diff --git a/scripts/check-desktop-runtime-errors/utils/run-command.js b/scripts/check-desktop-runtime-errors/utils/run-command.js new file mode 100644 index 000000000..6ea2baf68 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/utils/run-command.js @@ -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'); +} diff --git a/scripts/check-desktop-runtime-errors/utils/text.js b/scripts/check-desktop-runtime-errors/utils/text.js new file mode 100644 index 000000000..1a21d3ef2 --- /dev/null +++ b/scripts/check-desktop-runtime-errors/utils/text.js @@ -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}`); + } +} diff --git a/src/presentation/electron/main.ts b/src/presentation/electron/main.ts index cbe045da2..fcd10a5ea 100644 --- a/src/presentation/electron/main.ts +++ b/src/presentation/electron/main.ts @@ -11,6 +11,9 @@ import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; import log from 'electron-log'; import { setupAutoUpdater } from './Update/Updater'; +const commandLineFlags = Object.freeze({ + disableGpu: '--disable-gpu', +}); const isDevelopment = process.env.NODE_ENV !== 'production'; // Path of static assets, magic variable populated by electron @@ -26,10 +29,8 @@ protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } }, ]); -log.transports.file.level = 'silly'; -if (!process.env.IS_TEST) { - Object.assign(console, log.functions); // override console.log, console.warn etc. -} +setupLogger(); +disableGpu(); function createWindow() { // Create the browser window. @@ -131,6 +132,9 @@ function loadApplication(window: BrowserWindow) { const updater = setupAutoUpdater(); 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) { @@ -155,3 +159,19 @@ function getWindowSize(idealWidth: number, idealHeight: number) { height = Math.min(height, idealHeight); return { width, height }; } + +function disableGpu() { + console.log(process.argv); + const shouldDisableGpu = process.argv.includes(commandLineFlags.disableGpu); + if (shouldDisableGpu) { + console.log(`Hardware acceleration disabled via ${commandLineFlags.disableGpu} flag.`); + app.disableHardwareAcceleration(); + } +} + +function setupLogger(): void { + log.transports.file.level = 'silly'; + if (!process.env.IS_TEST) { + Object.assign(console, log.functions); // override console.log, console.warn etc. + } +}