Improve desktop runtime execution tests
Test improvements: - Capture titles for all macOS windows, not just the frontmost. - Incorporate missing application log files. - Improve log clarity with enriched context. - Improve application termination on macOS by reducing grace period. - Ensure complete application termination on macOS. - Validate Vue application loading through an initial log. - Support ignoring environment-specific `stderr` errors. - Do not fail the test if working directory cannot be deleted. - Use retry pattern when installing dependencies due to network errors. Refactorings: - Migrate the test code to TypeScript. - Replace deprecated `rmdir` with `rm` for error-resistant directory removal. - Improve sanity checking by shifting from App.vue to Vue bootstrapper. - Centralize environment variable management with `EnvironmentVariables` construct. - Rename infrastructure/Environment to RuntimeEnvironment for clarity. - Isolate WindowVariables and SystemOperations from RuntimeEnvironment. - Inject logging via preloader. - Correct mislabeled RuntimeSanity tests. Configuration: - Introduce `npm run check:desktop` for simplified execution. - Omit `console.log` override due to `nodeIntegration` restrictions and reveal logging functionality using context-bridging.
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
}
|
||||
82
scripts/check-desktop-runtime-errors/app/app-logs.ts
Normal file
82
scripts/check-desktop-runtime-errors/app/app-logs.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { unlink, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { log, die, LogLevel } from '../utils/log';
|
||||
import { exists } from '../utils/io';
|
||||
import { SupportedPlatform, CURRENT_PLATFORM } from '../utils/platform';
|
||||
import { getAppName } from '../utils/npm';
|
||||
|
||||
const LOG_FILE_NAMES = ['main', 'renderer'];
|
||||
|
||||
export async function clearAppLogFiles(
|
||||
projectDir: string,
|
||||
): Promise<void> {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
await Promise.all(LOG_FILE_NAMES.map(async (logFileName) => {
|
||||
const logPath = await determineLogPath(projectDir, logFileName);
|
||||
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: string,
|
||||
logFileName: string,
|
||||
): Promise<AppLogFileResult> {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
const logPath = await determineLogPath(projectDir, logFileName);
|
||||
if (!logPath || !await exists(logPath)) {
|
||||
log(`No log file at: ${logPath}`, LogLevel.Warn);
|
||||
return {
|
||||
logFilePath: logPath,
|
||||
};
|
||||
}
|
||||
const logContent = await readLogFile(logPath);
|
||||
return {
|
||||
logFileContent: logContent,
|
||||
logFilePath: logPath,
|
||||
};
|
||||
}
|
||||
|
||||
interface AppLogFileResult {
|
||||
readonly logFilePath: string;
|
||||
readonly logFileContent?: string;
|
||||
}
|
||||
|
||||
async function determineLogPath(
|
||||
projectDir: string,
|
||||
logFileName: string,
|
||||
): Promise<string> {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
if (!LOG_FILE_NAMES.includes(logFileName)) { throw new Error(`unknown log file name: ${logFileName}`); }
|
||||
const appName = await getAppName(projectDir);
|
||||
if (!appName) {
|
||||
return die('App name not found.');
|
||||
}
|
||||
const logFilePaths: {
|
||||
readonly [K in SupportedPlatform]: () => string;
|
||||
} = {
|
||||
[SupportedPlatform.macOS]: () => join(process.env.HOME, 'Library', 'Logs', appName, `${logFileName}.log`),
|
||||
[SupportedPlatform.Linux]: () => join(process.env.HOME, '.config', appName, 'logs', `${logFileName}.log`),
|
||||
[SupportedPlatform.Windows]: () => join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', `${logFileName}.log`),
|
||||
};
|
||||
const logFilePath = logFilePaths[CURRENT_PLATFORM]?.();
|
||||
if (!logFilePath) {
|
||||
log(`Cannot determine log path, unsupported OS: ${SupportedPlatform[CURRENT_PLATFORM]}`, LogLevel.Warn);
|
||||
}
|
||||
return logFilePath;
|
||||
}
|
||||
|
||||
async function readLogFile(
|
||||
logFilePath: string,
|
||||
): Promise<string | undefined> {
|
||||
const content = await readFile(logFilePath, 'utf-8');
|
||||
return content?.trim().length > 0 ? content : undefined;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { splitTextIntoLines, indentText } from '../utils/text.js';
|
||||
import { die } from '../utils/log.js';
|
||||
import { readAppLogFile } from './app-logs.js';
|
||||
|
||||
const ELECTRON_CRASH_TITLE = 'Error'; // Used by electron for early crashes
|
||||
const LOG_ERROR_MARKER = '[error]'; // from electron-log
|
||||
const EXPECTED_LOG_MARKERS = [
|
||||
'[WINDOW_INIT]',
|
||||
'[PRELOAD_INIT]',
|
||||
'[APP_MOUNT_INIT]',
|
||||
];
|
||||
|
||||
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),
|
||||
verifyApplicationLogsExist(logContent),
|
||||
...EXPECTED_LOG_MARKERS.map((marker) => verifyLogMarkerExistsInLogs(logContent, marker)),
|
||||
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 verifyApplicationLogsExist(logContent) {
|
||||
if (!logContent || !logContent.length) {
|
||||
return describeError(
|
||||
'Missing application logs',
|
||||
'Application logs are empty not were not found.',
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function verifyLogMarkerExistsInLogs(logContent, marker) {
|
||||
if (!marker) {
|
||||
throw new Error('missing marker');
|
||||
}
|
||||
if (!logContent?.includes(marker)) {
|
||||
return describeError(
|
||||
'Incomplete application logs',
|
||||
`Missing identifier "${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}\n\nThis might indicate an early crash or significant runtime issue.`,
|
||||
};
|
||||
}
|
||||
|
||||
function getNonEmptyLines(text) {
|
||||
return splitTextIntoLines(text)
|
||||
.filter((line) => line?.trim().length > 0);
|
||||
}
|
||||
181
scripts/check-desktop-runtime-errors/app/check-for-errors.ts
Normal file
181
scripts/check-desktop-runtime-errors/app/check-for-errors.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { splitTextIntoLines, indentText } from '../utils/text';
|
||||
import { log, die } from '../utils/log';
|
||||
import { readAppLogFile } from './app-logs';
|
||||
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
|
||||
|
||||
const ELECTRON_CRASH_TITLE = 'Error'; // Used by electron for early crashes
|
||||
const LOG_ERROR_MARKER = '[error]'; // from electron-log
|
||||
const EXPECTED_LOG_MARKERS = [
|
||||
'[WINDOW_INIT]',
|
||||
'[PRELOAD_INIT]',
|
||||
'[APP_INIT]',
|
||||
];
|
||||
|
||||
type ProcessType = 'main' | 'renderer';
|
||||
|
||||
export async function checkForErrors(
|
||||
stderr: string,
|
||||
windowTitles: readonly string[],
|
||||
projectDir: string,
|
||||
) {
|
||||
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: string,
|
||||
windowTitles: readonly string[],
|
||||
projectDir: string,
|
||||
): Promise<ExecutionError[]> {
|
||||
if (!projectDir) { throw new Error('missing project directory'); }
|
||||
const { logFileContent: mainLogs, logFilePath: mainLogFile } = await readAppLogFile(projectDir, 'main');
|
||||
const { logFileContent: rendererLogs, logFilePath: rendererLogFile } = await readAppLogFile(projectDir, 'renderer');
|
||||
const allLogs = [mainLogs, rendererLogs, stderr].filter(Boolean).join('\n');
|
||||
return [
|
||||
verifyStdErr(stderr),
|
||||
verifyApplicationLogsExist('main', mainLogs, mainLogFile),
|
||||
verifyApplicationLogsExist('renderer', rendererLogs, rendererLogFile),
|
||||
...EXPECTED_LOG_MARKERS.map(
|
||||
(marker) => verifyLogMarkerExistsInLogs(allLogs, marker),
|
||||
),
|
||||
verifyWindowTitle(windowTitles),
|
||||
verifyErrorsInLogs(allLogs),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
interface ExecutionError {
|
||||
readonly reason: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
function formatErrors(errors: readonly ExecutionError[]): string {
|
||||
if (!errors?.length) { throw new Error('missing errors'); }
|
||||
return [
|
||||
'Errors detected during execution:',
|
||||
...errors.map(
|
||||
(error) => formatError(error),
|
||||
),
|
||||
].join('\n---\n');
|
||||
}
|
||||
|
||||
function formatError(error: ExecutionError): string {
|
||||
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 verifyApplicationLogsExist(
|
||||
processType: ProcessType,
|
||||
logContent: string | undefined,
|
||||
logFilePath: string,
|
||||
): ExecutionError | undefined {
|
||||
if (!logContent?.length) {
|
||||
return describeError(
|
||||
`Missing application (${processType}) logs`,
|
||||
'Application logs are empty not were not found.'
|
||||
+ `\nLog path: ${logFilePath}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function verifyLogMarkerExistsInLogs(
|
||||
logContent: string | undefined,
|
||||
marker: string,
|
||||
) : ExecutionError | undefined {
|
||||
if (!marker) {
|
||||
throw new Error('missing marker');
|
||||
}
|
||||
if (!logContent?.includes(marker)) {
|
||||
return describeError(
|
||||
'Incomplete application logs',
|
||||
`Missing identifier "${marker}" in application logs.`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function verifyWindowTitle(
|
||||
windowTitles: readonly string[],
|
||||
) : ExecutionError | undefined {
|
||||
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: string | undefined,
|
||||
) : ExecutionError | undefined {
|
||||
if (stderrOutput && stderrOutput.length > 0) {
|
||||
const ignoredErrorLines = new Set();
|
||||
const relevantErrors = getNonEmptyLines(stderrOutput)
|
||||
.filter((line) => {
|
||||
line = line.trim();
|
||||
if (STDERR_IGNORE_PATTERNS.some((pattern) => pattern.test(line))) {
|
||||
ignoredErrorLines.add(line);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (ignoredErrorLines.size > 0) {
|
||||
log(`Ignoring \`stderr\` lines:\n${indentText([...ignoredErrorLines].join('\n'), 1)}`);
|
||||
}
|
||||
if (relevantErrors.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return describeError(
|
||||
'Standard error stream (`stderr`) is not empty.',
|
||||
`Relevant errors (${relevantErrors.length}):\n${indentText(relevantErrors.map((error) => `- ${error}`).join('\n'), 1)}`
|
||||
+ `\nFull \`stderr\` output:\n${indentText(stderrOutput, 1)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function verifyErrorsInLogs(
|
||||
logContent: string | undefined,
|
||||
) : ExecutionError | undefined {
|
||||
if (!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: string,
|
||||
description: string,
|
||||
) : ExecutionError | undefined {
|
||||
return {
|
||||
reason,
|
||||
description: `${description}\n\nThis might indicate an early crash or significant runtime issue.`,
|
||||
};
|
||||
}
|
||||
|
||||
function getNonEmptyLines(text: string) {
|
||||
return splitTextIntoLines(text)
|
||||
.filter((line) => line?.trim().length > 0);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* eslint-disable vue/max-len */
|
||||
|
||||
/* Ignore errors specific to host environment, rather than application execution */
|
||||
export const STDERR_IGNORE_PATTERNS: readonly RegExp[] = [
|
||||
/*
|
||||
OS: Linux
|
||||
Background:
|
||||
GLIBC and libgiolibproxy.so were seen on local Linux (Ubuntu-based) installation.
|
||||
Original logs:
|
||||
/snap/core20/current/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.29' not found (required by /lib/x86_64-linux-gnu/libproxy.so.1)
|
||||
Failed to load module: /home/bob/snap/code/common/.cache/gio-modules/libgiolibproxy.so
|
||||
[334053:0829/122143.595703:ERROR:browser_main_loop.cc(274)] GLib: Failed to set scheduler settings: Operation not permitted
|
||||
*/
|
||||
/libstdc\+\+\.so.*?GLIBCXX_.*?not found/,
|
||||
/Failed to load module: .*?libgiolibproxy\.so/,
|
||||
/\[.*?:ERROR:browser_main_loop\.cc.*?\] GLib: Failed to set scheduler settings: Operation not permitted/,
|
||||
|
||||
/*
|
||||
OS: macOS
|
||||
Background:
|
||||
Observed when running on GitHub runner, but not on local macOS environment.
|
||||
Original logs:
|
||||
[1571:0828/162611.460587:ERROR:trust_store_mac.cc(844)] Error parsing certificate:
|
||||
ERROR: Failed parsing extensions
|
||||
*/
|
||||
/ERROR:trust_store_mac\.cc.*?Error parsing certificate:/,
|
||||
/ERROR: Failed parsing extensions/,
|
||||
|
||||
/*
|
||||
OS: Linux (GitHub Actions)
|
||||
Background:
|
||||
Occur during Electron's GPU process initialization. Common in headless CI/CD environments.
|
||||
Not indicative of a problem in typical desktop environments.
|
||||
Original logs:
|
||||
[3548:0828/162502.835833:ERROR:viz_main_impl.cc(186)] Exiting GPU process due to errors during initialization
|
||||
[3627:0828/162503.133178:ERROR:viz_main_impl.cc(186)] Exiting GPU process due to errors during initialization
|
||||
[3621:0828/162503.420173:ERROR:command_buffer_proxy_impl.cc(128)] ContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBuffer.
|
||||
*/
|
||||
/ERROR:viz_main_impl\.cc.*?Exiting GPU process due to errors during initialization/,
|
||||
/ERROR:command_buffer_proxy_impl\.cc.*?ContextResult::kTransientFailure: Failed to send GpuControl\.CreateCommandBuffer\./,
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ExtractionResult {
|
||||
readonly appExecutablePath: string;
|
||||
readonly cleanup?: () => Promise<void>;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { access, chmod } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { findSingleFileByExtension } from '../../utils/io.js';
|
||||
import { log } from '../../utils/log.js';
|
||||
import { findSingleFileByExtension } from '../../utils/io';
|
||||
import { log } from '../../utils/log';
|
||||
import { ExtractionResult } from './extraction-result';
|
||||
|
||||
export async function prepareLinuxApp(desktopDistPath) {
|
||||
export async function prepareLinuxApp(
|
||||
desktopDistPath: string,
|
||||
): Promise<ExtractionResult> {
|
||||
const { absolutePath: appFile } = await findSingleFileByExtension(
|
||||
'AppImage',
|
||||
desktopDistPath,
|
||||
@@ -14,7 +17,7 @@ export async function prepareLinuxApp(desktopDistPath) {
|
||||
};
|
||||
}
|
||||
|
||||
async function makeExecutable(appFile) {
|
||||
async function makeExecutable(appFile: string): Promise<void> {
|
||||
if (!appFile) { throw new Error('missing file'); }
|
||||
if (await isExecutable(appFile)) {
|
||||
log('AppImage is already executable.');
|
||||
@@ -24,7 +27,7 @@ async function makeExecutable(appFile) {
|
||||
await chmod(appFile, 0o755);
|
||||
}
|
||||
|
||||
async function isExecutable(file) {
|
||||
async function isExecutable(file: string): Promise<boolean> {
|
||||
try {
|
||||
await access(file, constants.X_OK);
|
||||
return true;
|
||||
@@ -1,8 +1,12 @@
|
||||
import { runCommand } from '../../utils/run-command.js';
|
||||
import { findSingleFileByExtension, exists } from '../../utils/io.js';
|
||||
import { log, die, LOG_LEVELS } from '../../utils/log.js';
|
||||
import { runCommand } from '../../utils/run-command';
|
||||
import { findSingleFileByExtension, exists } from '../../utils/io';
|
||||
import { log, die, LogLevel } from '../../utils/log';
|
||||
import { sleep } from '../../utils/sleep';
|
||||
import { ExtractionResult } from './extraction-result';
|
||||
|
||||
export async function prepareMacOsApp(desktopDistPath) {
|
||||
export async function prepareMacOsApp(
|
||||
desktopDistPath: string,
|
||||
): Promise<ExtractionResult> {
|
||||
const { absolutePath: dmgPath } = await findSingleFileByExtension('dmg', desktopDistPath);
|
||||
const { mountPath } = await mountDmg(dmgPath);
|
||||
const appPath = await findMacAppExecutablePath(mountPath);
|
||||
@@ -15,8 +19,12 @@ export async function prepareMacOsApp(desktopDistPath) {
|
||||
};
|
||||
}
|
||||
|
||||
async function mountDmg(dmgFile) {
|
||||
const { stdout: hdiutilOutput, error } = await runCommand(`hdiutil attach '${dmgFile}'`);
|
||||
async function mountDmg(
|
||||
dmgFile: string,
|
||||
) {
|
||||
const { stdout: hdiutilOutput, error } = await runCommand(
|
||||
`hdiutil attach '${dmgFile}'`,
|
||||
);
|
||||
if (error) {
|
||||
die(`Failed to mount DMG file at ${dmgFile}.\n${error}`);
|
||||
}
|
||||
@@ -27,12 +35,14 @@ async function mountDmg(dmgFile) {
|
||||
};
|
||||
}
|
||||
|
||||
async function findMacAppExecutablePath(mountPath) {
|
||||
async function findMacAppExecutablePath(
|
||||
mountPath: string,
|
||||
): Promise<string> {
|
||||
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}`);
|
||||
return die(`Failed to find executable path at mount path ${mountPath}\n${error}`);
|
||||
}
|
||||
const appFolder = findOutput.trim();
|
||||
const appName = appFolder.split('/').pop().replace('.app', '');
|
||||
@@ -40,16 +50,19 @@ async function findMacAppExecutablePath(mountPath) {
|
||||
if (await exists(appPath)) {
|
||||
log(`Application is located at ${appPath}`);
|
||||
} else {
|
||||
die(`Application does not exist at ${appPath}`);
|
||||
return die(`Application does not exist at ${appPath}`);
|
||||
}
|
||||
return appPath;
|
||||
}
|
||||
|
||||
async function detachMount(mountPath, retries = 5) {
|
||||
async function detachMount(
|
||||
mountPath: string,
|
||||
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);
|
||||
log(`Failed to detach mount after multiple attempts: ${mountPath}\n${error}`, LogLevel.Warn);
|
||||
return;
|
||||
}
|
||||
await sleep(500);
|
||||
@@ -58,9 +71,3 @@ async function detachMount(mountPath, retries = 5) {
|
||||
}
|
||||
log(`Successfully detached from ${mountPath}`);
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, milliseconds);
|
||||
});
|
||||
}
|
||||
@@ -1,38 +1,46 @@
|
||||
import { mkdtemp, rmdir } from 'fs/promises';
|
||||
import { mkdtemp, rm } 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';
|
||||
import { findSingleFileByExtension, exists } from '../../utils/io';
|
||||
import { log, die, LogLevel } from '../../utils/log';
|
||||
import { runCommand } from '../../utils/run-command';
|
||||
import { ExtractionResult } from './extraction-result';
|
||||
|
||||
export async function prepareWindowsApp(desktopDistPath) {
|
||||
export async function prepareWindowsApp(
|
||||
desktopDistPath: string,
|
||||
): Promise<ExtractionResult> {
|
||||
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 });
|
||||
await rm(workdir, { recursive: true });
|
||||
}
|
||||
const { appExecutablePath } = await installNsis(workdir, desktopDistPath);
|
||||
const appExecutablePath = await installNsis(workdir, desktopDistPath);
|
||||
return {
|
||||
appExecutablePath,
|
||||
cleanup: async () => {
|
||||
log(`Cleaning up working directory ${workdir}...`);
|
||||
await rmdir(workdir, { recursive: true });
|
||||
try {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
log(`Could not cleanup the working directory: ${error.message}`, LogLevel.Error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function installNsis(installationPath, desktopDistPath) {
|
||||
async function installNsis(
|
||||
installationPath: string,
|
||||
desktopDistPath: string,
|
||||
): Promise<string> {
|
||||
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}`);
|
||||
return die(`Failed to install.\n${error}`);
|
||||
}
|
||||
|
||||
const { absolutePath: appExecutablePath } = await findSingleFileByExtension('exe', installationPath);
|
||||
|
||||
return {
|
||||
appExecutablePath,
|
||||
};
|
||||
return appExecutablePath;
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
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';
|
||||
import { log, LogLevel, die } from '../utils/log';
|
||||
import { captureScreen } from './system-capture/screen-capture';
|
||||
import { captureWindowTitles } from './system-capture/window-title-capture';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
const TERMINATION_GRACE_PERIOD_IN_SECONDS = 60;
|
||||
const TERMINATION_GRACE_PERIOD_IN_SECONDS = 20;
|
||||
const TERMINATION_CHECK_INTERVAL_IN_MS = 1000;
|
||||
const WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS = 100;
|
||||
|
||||
export function runApplication(
|
||||
appFile,
|
||||
executionDurationInSeconds,
|
||||
enableScreenshot,
|
||||
screenshotPath,
|
||||
) {
|
||||
appFile: string,
|
||||
executionDurationInSeconds: number,
|
||||
enableScreenshot: boolean,
|
||||
screenshotPath: string,
|
||||
): Promise<ApplicationExecutionResult> {
|
||||
if (!appFile) {
|
||||
throw new Error('Missing app file');
|
||||
}
|
||||
|
||||
logDetails(appFile, executionDurationInSeconds);
|
||||
|
||||
const processDetails = {
|
||||
const processDetails: ApplicationProcessDetails = {
|
||||
stderrData: '',
|
||||
stdoutData: '',
|
||||
explicitlyKilled: false,
|
||||
@@ -35,7 +36,7 @@ export function runApplication(
|
||||
|
||||
return new Promise((resolve) => {
|
||||
processDetails.resolve = resolve;
|
||||
handleTitleCapture(process.pid, processDetails);
|
||||
beginCapturingTitles(process.pid, processDetails);
|
||||
handleProcessEvents(
|
||||
processDetails,
|
||||
enableScreenshot,
|
||||
@@ -45,7 +46,28 @@ export function runApplication(
|
||||
});
|
||||
}
|
||||
|
||||
function logDetails(appFile, executionDurationInSeconds) {
|
||||
interface ApplicationExecutionResult {
|
||||
readonly stderr: string,
|
||||
readonly stdout: string,
|
||||
readonly windowTitles: readonly string[],
|
||||
readonly isCrashed: boolean,
|
||||
}
|
||||
|
||||
interface ApplicationProcessDetails {
|
||||
stderrData: string;
|
||||
stdoutData: string;
|
||||
explicitlyKilled: boolean;
|
||||
windowTitles: Array<string>;
|
||||
isCrashed: boolean;
|
||||
isDone: boolean;
|
||||
process: ChildProcess;
|
||||
resolve: (value: ApplicationExecutionResult) => void;
|
||||
}
|
||||
|
||||
function logDetails(
|
||||
appFile: string,
|
||||
executionDurationInSeconds: number,
|
||||
): void {
|
||||
log(
|
||||
[
|
||||
'Executing the app to check for errors...',
|
||||
@@ -55,12 +77,15 @@ function logDetails(appFile, executionDurationInSeconds) {
|
||||
);
|
||||
}
|
||||
|
||||
function handleTitleCapture(processId, processDetails) {
|
||||
function beginCapturingTitles(
|
||||
processId: number,
|
||||
processDetails: ApplicationProcessDetails,
|
||||
): void {
|
||||
const capture = async () => {
|
||||
const titles = await captureWindowTitles(processId);
|
||||
|
||||
(titles || []).forEach((title) => {
|
||||
if (!title || !title.length) {
|
||||
if (!title?.length) {
|
||||
return;
|
||||
}
|
||||
if (!processDetails.windowTitles.includes(title)) {
|
||||
@@ -78,11 +103,11 @@ function handleTitleCapture(processId, processDetails) {
|
||||
}
|
||||
|
||||
function handleProcessEvents(
|
||||
processDetails,
|
||||
enableScreenshot,
|
||||
screenshotPath,
|
||||
executionDurationInSeconds,
|
||||
) {
|
||||
processDetails: ApplicationProcessDetails,
|
||||
enableScreenshot: boolean,
|
||||
screenshotPath: string,
|
||||
executionDurationInSeconds: number,
|
||||
): void {
|
||||
const { process } = processDetails;
|
||||
process.stderr.on('data', (data) => {
|
||||
processDetails.stderrData += data.toString();
|
||||
@@ -92,7 +117,7 @@ function handleProcessEvents(
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
die(`An issue spawning the child process: ${error}`, LOG_LEVELS.ERROR);
|
||||
die(`An issue spawning the child process: ${error}`);
|
||||
});
|
||||
|
||||
process.on('exit', async (code) => {
|
||||
@@ -100,11 +125,16 @@ function handleProcessEvents(
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
await onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath);
|
||||
await onExecutionLimitReached(processDetails, enableScreenshot, screenshotPath);
|
||||
}, executionDurationInSeconds * 1000);
|
||||
}
|
||||
|
||||
async function onProcessExit(code, processDetails, enableScreenshot, screenshotPath) {
|
||||
async function onProcessExit(
|
||||
code: number,
|
||||
processDetails: ApplicationProcessDetails,
|
||||
enableScreenshot: boolean,
|
||||
screenshotPath: string,
|
||||
): Promise<void> {
|
||||
log(`Application exited ${code === null || Number.isNaN(code) ? '.' : `with code ${code}`}`);
|
||||
|
||||
if (processDetails.explicitlyKilled) return;
|
||||
@@ -118,17 +148,21 @@ async function onProcessExit(code, processDetails, enableScreenshot, screenshotP
|
||||
finishProcess(processDetails);
|
||||
}
|
||||
|
||||
async function onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath) {
|
||||
async function onExecutionLimitReached(
|
||||
processDetails: ApplicationProcessDetails,
|
||||
enableScreenshot: boolean,
|
||||
screenshotPath: string,
|
||||
): Promise<void> {
|
||||
if (enableScreenshot) {
|
||||
await captureScreen(screenshotPath);
|
||||
}
|
||||
|
||||
processDetails.explicitlyKilled = true;
|
||||
await terminateGracefully(process);
|
||||
await terminateGracefully(processDetails.process);
|
||||
finishProcess(processDetails);
|
||||
}
|
||||
|
||||
function finishProcess(processDetails) {
|
||||
function finishProcess(processDetails: ApplicationProcessDetails): void {
|
||||
processDetails.isDone = true;
|
||||
processDetails.resolve({
|
||||
stderr: processDetails.stderrData,
|
||||
@@ -138,7 +172,9 @@ function finishProcess(processDetails) {
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateGracefully(process) {
|
||||
async function terminateGracefully(
|
||||
process: ChildProcess,
|
||||
): Promise<void> {
|
||||
let elapsedSeconds = 0;
|
||||
log('Attempting to terminate the process gracefully...');
|
||||
process.kill('SIGTERM');
|
||||
@@ -147,18 +183,18 @@ async function terminateGracefully(process) {
|
||||
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.');
|
||||
if (elapsedSeconds >= TERMINATION_GRACE_PERIOD_IN_SECONDS) {
|
||||
process.kill('SIGKILL');
|
||||
log('Process did not terminate gracefully within the grace period. Forcing termination.', LogLevel.Warn);
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, TERMINATION_CHECK_INTERVAL_IN_MS);
|
||||
|
||||
process.on('exit', () => {
|
||||
log('Process terminated gracefully.');
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,29 +1,33 @@
|
||||
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';
|
||||
import { runCommand } from '../../utils/run-command';
|
||||
import { log, LogLevel } from '../../utils/log';
|
||||
import { CURRENT_PLATFORM, SupportedPlatform } from '../../utils/platform';
|
||||
import { exists } from '../../utils/io';
|
||||
|
||||
export async function captureScreen(imagePath) {
|
||||
export async function captureScreen(
|
||||
imagePath: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
log(`Screenshot file already exists at ${imagePath}. It will be overwritten.`, LogLevel.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 platformCommands: {
|
||||
readonly [K in SupportedPlatform]: string;
|
||||
} = {
|
||||
[SupportedPlatform.macOS]: `screencapture -x ${imagePath}`,
|
||||
[SupportedPlatform.Linux]: `import -window root ${imagePath}`,
|
||||
[SupportedPlatform.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);
|
||||
log(`Screenshot capture not supported on: ${SupportedPlatform[CURRENT_PLATFORM]}`, LogLevel.Warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,13 +35,13 @@ export async function captureScreen(imagePath) {
|
||||
|
||||
const { error } = await runCommand(commandForPlatform);
|
||||
if (error) {
|
||||
log(`Failed to capture screenshot.\n${error}`, LOG_LEVELS.WARN);
|
||||
log(`Failed to capture screenshot.\n${error}`, LogLevel.Warn);
|
||||
return;
|
||||
}
|
||||
log(`Captured screenshot to ${imagePath}.`);
|
||||
}
|
||||
|
||||
function getScreenshotPowershellScript(imagePath) {
|
||||
function getScreenshotPowershellScript(imagePath: string): string {
|
||||
return `
|
||||
$ProgressPreference = 'SilentlyContinue' # Do not pollute stderr
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
@@ -53,7 +57,7 @@ function getScreenshotPowershellScript(imagePath) {
|
||||
`;
|
||||
}
|
||||
|
||||
function encodeForPowershell(script) {
|
||||
const buffer = Buffer.from(script, 'utf-16le');
|
||||
function encodeForPowershell(script: string): string {
|
||||
const buffer = Buffer.from(script, 'utf16le');
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
@@ -1,37 +1,40 @@
|
||||
import { runCommand } from '../../utils/run-command.js';
|
||||
import { log, LOG_LEVELS } from '../../utils/log.js';
|
||||
import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../../utils/platform.js';
|
||||
import { runCommand } from '../../utils/run-command';
|
||||
import { log, LogLevel } from '../../utils/log';
|
||||
import { SupportedPlatform, CURRENT_PLATFORM } from '../../utils/platform';
|
||||
|
||||
export async function captureWindowTitles(processId) {
|
||||
export async function captureWindowTitles(processId: number) {
|
||||
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);
|
||||
log(`Cannot capture window title, unsupported OS: ${SupportedPlatform[CURRENT_PLATFORM]}`, LogLevel.Warn);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return captureFunction(processId);
|
||||
}
|
||||
|
||||
const windowTitleCaptureFunctions = {
|
||||
[SUPPORTED_PLATFORMS.MAC]: captureTitlesOnMac,
|
||||
[SUPPORTED_PLATFORMS.LINUX]: captureTitlesOnLinux,
|
||||
[SUPPORTED_PLATFORMS.WINDOWS]: captureTitlesOnWindows,
|
||||
const windowTitleCaptureFunctions: {
|
||||
readonly [K in SupportedPlatform]: (processId: number) => Promise<string[]>;
|
||||
} = {
|
||||
[SupportedPlatform.macOS]: (processId) => captureTitlesOnMac(processId),
|
||||
[SupportedPlatform.Linux]: (processId) => captureTitlesOnLinux(processId),
|
||||
[SupportedPlatform.Windows]: (processId) => captureTitlesOnWindows(processId),
|
||||
};
|
||||
|
||||
async function captureTitlesOnWindows(processId) {
|
||||
async function captureTitlesOnWindows(processId: number): Promise<string[]> {
|
||||
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);
|
||||
log(`Failed to retrieve window title.\n${error}`, LogLevel.Warn);
|
||||
return [];
|
||||
}
|
||||
const match = tasklistOutput.match(/Window Title:\s*(.*)/);
|
||||
if (match && match[1]) {
|
||||
const regex = /Window Title:\s*(.*)/;
|
||||
const match = regex.exec(tasklistOutput);
|
||||
if (match && match.length > 1 && match[1]) {
|
||||
const title = match[1].trim();
|
||||
if (title === 'N/A') {
|
||||
return [];
|
||||
@@ -41,7 +44,7 @@ async function captureTitlesOnWindows(processId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async function captureTitlesOnLinux(processId) {
|
||||
async function captureTitlesOnLinux(processId: number): Promise<string[]> {
|
||||
if (!processId) { throw new Error('Missing process ID.'); }
|
||||
|
||||
const { stdout: windowIdsOutput, error: windowIdError } = await runCommand(
|
||||
@@ -49,7 +52,7 @@ async function captureTitlesOnLinux(processId) {
|
||||
);
|
||||
|
||||
if (windowIdError || !windowIdsOutput) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
const windowIds = windowIdsOutput.trim().split('\n');
|
||||
@@ -69,23 +72,24 @@ async function captureTitlesOnLinux(processId) {
|
||||
|
||||
let hasAssistiveAccessOnMac = true;
|
||||
|
||||
async function captureTitlesOnMac(processId) {
|
||||
async function captureTitlesOnMac(processId: number): Promise<string[]> {
|
||||
if (!processId) { throw new Error('Missing process ID.'); }
|
||||
if (!hasAssistiveAccessOnMac) {
|
||||
return [];
|
||||
}
|
||||
const script = `
|
||||
tell application "System Events"
|
||||
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
|
||||
set allWindowNames to {}
|
||||
repeat with aWindow in windows
|
||||
set end of allWindowNames to name of aWindow
|
||||
end repeat
|
||||
return allWindowNames
|
||||
end tell
|
||||
end tell
|
||||
`;
|
||||
@@ -102,7 +106,7 @@ async function captureTitlesOnMac(processId) {
|
||||
hasAssistiveAccessOnMac = false;
|
||||
}
|
||||
errorMessage += error;
|
||||
log(errorMessage, LOG_LEVELS.WARN);
|
||||
log(errorMessage, LogLevel.Warn);
|
||||
return [];
|
||||
}
|
||||
const title = titleOutput?.trim();
|
||||
Reference in New Issue
Block a user