Refactor and improve external URL checks

- Move external URL checks to its own module under `tests/`. This
  separates them from integration test, addressing long runs and
  frequent failures that led to ignoring test results.
- Move `check-desktop-runtime-errors` to `tests/checks` to keep all
  test-related checks into one directory.
- Replace `ts-node` with `vite` for running
  `check-desktop-runtime-errors` to maintain a consistent execution
  environment across checks.
- Implement a timeout for each fetch call.
- Be nice to external sources, wait 5 seconds before sending another
  request to an URL under same domain. This solves rate-limiting issues.
- Instead of running test on every push/pull request, run them only
  weekly.
- Do not run tests on each commit/PR but only scheduled (weekly) to
  minimize noise.
- Fix URLs are not captured correctly inside backticks or parenthesis.
This commit is contained in:
undergroundwires
2023-09-01 00:18:47 +02:00
parent f4d86fccfd
commit 19e42c9c52
43 changed files with 400 additions and 449 deletions

View 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;
}

View 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);
}

View File

@@ -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\./,
];

View File

@@ -0,0 +1,46 @@
import { join } from 'path';
import { readdir } from 'fs/promises';
import { die } from '../../../utils/log';
import { exists } from '../../../utils/io';
import { getAppName } from '../../../utils/npm';
export async function findByFilePattern(
pattern: string,
directory: string,
projectRootDir: string,
): Promise<ArtifactLocation> {
if (!directory) { throw new Error('Missing directory'); }
if (!pattern) { throw new Error('Missing file pattern'); }
if (!await exists(directory)) {
return die(`Directory does not exist: ${directory}`);
}
const directoryContents = await readdir(directory);
const appName = await getAppName(projectRootDir);
const regexPattern = pattern
/* eslint-disable no-template-curly-in-string */
.replaceAll('${name}', escapeRegExp(appName))
.replaceAll('${version}', '\\d+\\.\\d+\\.\\d+')
.replaceAll('${ext}', '.*');
/* eslint-enable no-template-curly-in-string */
const regex = new RegExp(`^${regexPattern}$`);
const foundFileNames = directoryContents.filter((file) => regex.test(file));
if (!foundFileNames.length) {
return die(`No files found matching pattern "${pattern}" in ${directory} directory.`);
}
if (foundFileNames.length > 1) {
return die(`Found multiple files matching pattern "${pattern}": ${foundFileNames.join(', ')}`);
}
return {
absolutePath: join(directory, foundFileNames[0]),
};
}
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
interface ArtifactLocation {
readonly absolutePath?: string;
}

View File

@@ -0,0 +1,4 @@
export interface ExtractionResult {
readonly appExecutablePath: string;
readonly cleanup?: () => Promise<void>;
}

View File

@@ -0,0 +1,40 @@
import { access, chmod } from 'fs/promises';
import { constants } from 'fs';
import { log } from '../../utils/log';
import { ExtractionResult } from './common/extraction-result';
import { findByFilePattern } from './common/app-artifact-locator';
export async function prepareLinuxApp(
desktopDistPath: string,
projectRootDir: string,
): Promise<ExtractionResult> {
const { absolutePath: appFile } = await findByFilePattern(
// eslint-disable-next-line no-template-curly-in-string
'${name}-${version}.AppImage',
desktopDistPath,
projectRootDir,
);
await makeExecutable(appFile);
return {
appExecutablePath: appFile,
};
}
async function makeExecutable(appFile: string): Promise<void> {
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: string): Promise<boolean> {
try {
await access(file, constants.X_OK);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,80 @@
import { runCommand } from '../../utils/run-command';
import { exists } from '../../utils/io';
import { log, die, LogLevel } from '../../utils/log';
import { sleep } from '../../utils/sleep';
import { ExtractionResult } from './common/extraction-result';
import { findByFilePattern } from './common/app-artifact-locator';
export async function prepareMacOsApp(
desktopDistPath: string,
projectRootDir: string,
): Promise<ExtractionResult> {
const { absolutePath: dmgPath } = await findByFilePattern(
// eslint-disable-next-line no-template-curly-in-string
'${name}-${version}.dmg',
desktopDistPath,
projectRootDir,
);
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: string,
) {
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: string,
): Promise<string> {
const { stdout: findOutput, error } = await runCommand(
`find '${mountPath}' -maxdepth 1 -type d -name "*.app"`,
);
if (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', '');
const appPath = `${appFolder}/Contents/MacOS/${appName}`;
if (await exists(appPath)) {
log(`Application is located at ${appPath}`);
} else {
return die(`Application does not exist at ${appPath}`);
}
return appPath;
}
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}`, LogLevel.Warn);
return;
}
await sleep(500);
await detachMount(mountPath, retries - 1);
return;
}
log(`Successfully detached from ${mountPath}`);
}

View File

@@ -0,0 +1,58 @@
import { mkdtemp, rm } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { exists } from '../../utils/io';
import { log, die, LogLevel } from '../../utils/log';
import { runCommand } from '../../utils/run-command';
import { ExtractionResult } from './common/extraction-result';
import { findByFilePattern } from './common/app-artifact-locator';
export async function prepareWindowsApp(
desktopDistPath: string,
projectRootDir: 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 rm(workdir, { recursive: true });
}
const appExecutablePath = await installNsis(workdir, desktopDistPath, projectRootDir);
return {
appExecutablePath,
cleanup: async () => {
log(`Cleaning up working directory ${workdir}...`);
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: string,
desktopDistPath: string,
projectRootDir: string,
): Promise<string> {
const { absolutePath: installerPath } = await findByFilePattern(
// eslint-disable-next-line no-template-curly-in-string
'${name}-Setup-${version}.exe',
desktopDistPath,
projectRootDir,
);
log(`Silently installing contents of ${installerPath} to ${installationPath}...`);
const { error } = await runCommand(`"${installerPath}" /S /D=${installationPath}`);
if (error) {
return die(`Failed to install.\n${error}`);
}
const { absolutePath: appExecutablePath } = await findByFilePattern(
// eslint-disable-next-line no-template-curly-in-string
'${name}.exe',
installationPath,
projectRootDir,
);
return appExecutablePath;
}

View File

@@ -0,0 +1,200 @@
import { spawn } from 'child_process';
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 = 20;
const TERMINATION_CHECK_INTERVAL_IN_MS = 1000;
const WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS = 100;
export function runApplication(
appFile: string,
executionDurationInSeconds: number,
enableScreenshot: boolean,
screenshotPath: string,
): Promise<ApplicationExecutionResult> {
if (!appFile) {
throw new Error('Missing app file');
}
logDetails(appFile, executionDurationInSeconds);
const processDetails: ApplicationProcessDetails = {
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;
beginCapturingTitles(process.pid, processDetails);
handleProcessEvents(
processDetails,
enableScreenshot,
screenshotPath,
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...',
`Maximum execution time: ${executionDurationInSeconds}`,
`Application path: ${appFile}`,
].join('\n\t'),
);
}
function beginCapturingTitles(
processId: number,
processDetails: ApplicationProcessDetails,
): void {
const capture = async () => {
const titles = await captureWindowTitles(processId);
(titles || []).forEach((title) => {
if (!title?.length) {
return;
}
if (!processDetails.windowTitles.includes(title)) {
log(`New window title captured: ${title}`);
processDetails.windowTitles.push(title);
}
});
if (!processDetails.isDone) {
setTimeout(capture, WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS);
}
};
capture();
}
function handleProcessEvents(
processDetails: ApplicationProcessDetails,
enableScreenshot: boolean,
screenshotPath: string,
executionDurationInSeconds: number,
): void {
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}`);
});
process.on('exit', async (code) => {
await onProcessExit(code, processDetails, enableScreenshot, screenshotPath);
});
setTimeout(async () => {
await onExecutionLimitReached(processDetails, enableScreenshot, screenshotPath);
}, executionDurationInSeconds * 1000);
}
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;
processDetails.isCrashed = true;
if (enableScreenshot) {
await captureScreen(screenshotPath);
}
finishProcess(processDetails);
}
async function onExecutionLimitReached(
processDetails: ApplicationProcessDetails,
enableScreenshot: boolean,
screenshotPath: string,
): Promise<void> {
if (enableScreenshot) {
await captureScreen(screenshotPath);
}
processDetails.explicitlyKilled = true;
await terminateGracefully(processDetails.process);
finishProcess(processDetails);
}
function finishProcess(processDetails: ApplicationProcessDetails): void {
processDetails.isDone = true;
processDetails.resolve({
stderr: processDetails.stderrData,
stdout: processDetails.stdoutData,
windowTitles: [...processDetails.windowTitles],
isCrashed: processDetails.isCrashed,
});
}
async function terminateGracefully(
process: ChildProcess,
): Promise<void> {
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 (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();
});
});
}

View File

@@ -0,0 +1,63 @@
import { unlink } from 'fs/promises';
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: 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.`, LogLevel.Warn);
unlink(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: ${SupportedPlatform[CURRENT_PLATFORM]}`, LogLevel.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}`, LogLevel.Warn);
return;
}
log(`Captured screenshot to ${imagePath}.`);
}
function getScreenshotPowershellScript(imagePath: string): string {
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: string): string {
const buffer = Buffer.from(script, 'utf16le');
return buffer.toString('base64');
}

View File

@@ -0,0 +1,117 @@
import { runCommand } from '../../utils/run-command';
import { log, LogLevel } from '../../utils/log';
import { SupportedPlatform, CURRENT_PLATFORM } from '../../utils/platform';
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: ${SupportedPlatform[CURRENT_PLATFORM]}`, LogLevel.Warn);
return undefined;
}
return captureFunction(processId);
}
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: 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}`, LogLevel.Warn);
return [];
}
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 [];
}
return [title];
}
return [];
}
async function captureTitlesOnLinux(processId: number): Promise<string[]> {
if (!processId) { throw new Error('Missing process ID.'); }
const { stdout: windowIdsOutput, error: windowIdError } = await runCommand(
`xdotool search --pid '${processId}'`,
);
if (windowIdError || !windowIdsOutput) {
return [];
}
const windowIds = windowIdsOutput.trim().split('\n');
const titles = await Promise.all(windowIds.map(async (windowId) => {
const { stdout: titleOutput, error: titleError } = await runCommand(
`xprop -id ${windowId} | grep "WM_NAME(STRING)" | cut -d '=' -f 2 | sed 's/^[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/'`,
);
if (titleError || !titleOutput) {
return undefined;
}
return titleOutput.trim();
}));
return titles.filter(Boolean);
}
let hasAssistiveAccessOnMac = true;
async function captureTitlesOnMac(processId: number): Promise<string[]> {
if (!processId) { throw new Error('Missing process ID.'); }
if (!hasAssistiveAccessOnMac) {
return [];
}
const script = `
tell application "System Events"
try
set targetProcess to first process whose unix id is ${processId}
on error
return
end try
tell targetProcess
set allWindowNames to {}
repeat with aWindow in windows
set end of allWindowNames to name of aWindow
end repeat
return allWindowNames
end tell
end tell
`;
const argument = script.trim()
.split(/[\r\n]+/)
.map((line) => `-e '${line.trim()}'`)
.join(' ');
const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
if (error) {
let errorMessage = '';
if (error.includes('-25211')) {
errorMessage += 'Capturing window title requires assistive access. You do not have it.\n';
hasAssistiveAccessOnMac = false;
}
errorMessage += error;
log(errorMessage, LogLevel.Warn);
return [];
}
const title = titleOutput?.trim();
if (!title) {
return [];
}
return [title];
}