301 lines
8.6 KiB
TypeScript
301 lines
8.6 KiB
TypeScript
import { exec } from 'child_process';
|
||
import { promisify } from 'util';
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
/**
|
||
* Permission status for E2E tests requiring real automation.
|
||
*/
|
||
export interface E2EPermissionStatus {
|
||
accessibility: boolean;
|
||
screenRecording: boolean;
|
||
platform: NodeJS.Platform;
|
||
isCI: boolean;
|
||
isHeadless: boolean;
|
||
canRunRealAutomation: boolean;
|
||
}
|
||
|
||
/**
|
||
* Result of permission check with actionable information.
|
||
*/
|
||
export interface PermissionCheckResult {
|
||
canProceed: boolean;
|
||
shouldSkip: boolean;
|
||
skipReason?: string;
|
||
status: E2EPermissionStatus;
|
||
warnings: string[];
|
||
}
|
||
|
||
/**
|
||
* PermissionGuard for E2E tests.
|
||
*
|
||
* Checks macOS Accessibility and Screen Recording permissions
|
||
* required for real nut.js automation. Provides graceful skip
|
||
* logic for CI environments or when permissions are unavailable.
|
||
*/
|
||
export class PermissionGuard {
|
||
private cachedStatus: E2EPermissionStatus | null = null;
|
||
|
||
/**
|
||
* Check all permissions and determine if real automation tests can run.
|
||
*
|
||
* @returns PermissionCheckResult with status and skip reason if applicable
|
||
*/
|
||
async checkPermissions(): Promise<PermissionCheckResult> {
|
||
const status = await this.getPermissionStatus();
|
||
const warnings: string[] = [];
|
||
|
||
// CI environments should always skip real automation tests
|
||
if (status.isCI) {
|
||
return {
|
||
canProceed: false,
|
||
shouldSkip: true,
|
||
skipReason: 'Running in CI environment - real automation tests require a display',
|
||
status,
|
||
warnings: ['CI environment detected, skipping real automation tests'],
|
||
};
|
||
}
|
||
|
||
// Headless environments cannot run real automation
|
||
if (status.isHeadless) {
|
||
return {
|
||
canProceed: false,
|
||
shouldSkip: true,
|
||
skipReason: 'Running in headless environment - real automation tests require a display',
|
||
status,
|
||
warnings: ['Headless environment detected, skipping real automation tests'],
|
||
};
|
||
}
|
||
|
||
// macOS-specific permission checks
|
||
if (status.platform === 'darwin') {
|
||
if (!status.accessibility) {
|
||
warnings.push('macOS Accessibility permission not granted');
|
||
warnings.push('To grant: System Preferences > Security & Privacy > Privacy > Accessibility');
|
||
}
|
||
if (!status.screenRecording) {
|
||
warnings.push('macOS Screen Recording permission not granted');
|
||
warnings.push('To grant: System Preferences > Security & Privacy > Privacy > Screen Recording');
|
||
}
|
||
|
||
if (!status.accessibility || !status.screenRecording) {
|
||
return {
|
||
canProceed: false,
|
||
shouldSkip: true,
|
||
skipReason: `Missing macOS permissions: ${[
|
||
!status.accessibility && 'Accessibility',
|
||
!status.screenRecording && 'Screen Recording',
|
||
].filter(Boolean).join(', ')}`,
|
||
status,
|
||
warnings,
|
||
};
|
||
}
|
||
}
|
||
|
||
// All checks passed
|
||
return {
|
||
canProceed: true,
|
||
shouldSkip: false,
|
||
status,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get the current permission status.
|
||
*/
|
||
async getPermissionStatus(): Promise<E2EPermissionStatus> {
|
||
if (this.cachedStatus) {
|
||
return this.cachedStatus;
|
||
}
|
||
|
||
const platform = process.platform;
|
||
const isCI = this.detectCI();
|
||
const isHeadless = await this.detectHeadless();
|
||
|
||
let accessibility = true;
|
||
let screenRecording = true;
|
||
|
||
if (platform === 'darwin') {
|
||
accessibility = await this.checkMacOSAccessibility();
|
||
screenRecording = await this.checkMacOSScreenRecording();
|
||
}
|
||
|
||
const canRunRealAutomation =
|
||
!isCI &&
|
||
!isHeadless &&
|
||
accessibility &&
|
||
screenRecording;
|
||
|
||
this.cachedStatus = {
|
||
accessibility,
|
||
screenRecording,
|
||
platform,
|
||
isCI,
|
||
isHeadless,
|
||
canRunRealAutomation,
|
||
};
|
||
|
||
return this.cachedStatus;
|
||
}
|
||
|
||
/**
|
||
* Detect if running in a CI environment.
|
||
*/
|
||
private detectCI(): boolean {
|
||
return !!(
|
||
process.env.CI ||
|
||
process.env.CONTINUOUS_INTEGRATION ||
|
||
process.env.GITHUB_ACTIONS ||
|
||
process.env.GITLAB_CI ||
|
||
process.env.CIRCLECI ||
|
||
process.env.TRAVIS ||
|
||
process.env.JENKINS_URL ||
|
||
process.env.BUILDKITE ||
|
||
process.env.TF_BUILD // Azure DevOps
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Detect if running in a headless environment (no display).
|
||
*/
|
||
private async detectHeadless(): Promise<boolean> {
|
||
// Check for explicit headless environment variable
|
||
if (process.env.HEADLESS === 'true' || process.env.DISPLAY === '') {
|
||
return true;
|
||
}
|
||
|
||
// On Linux, check if DISPLAY is set
|
||
if (process.platform === 'linux' && !process.env.DISPLAY) {
|
||
return true;
|
||
}
|
||
|
||
// On macOS, check if we're in a non-GUI session
|
||
if (process.platform === 'darwin') {
|
||
try {
|
||
// Check if we can access the WindowServer
|
||
const { stdout } = await execAsync('pgrep -x WindowServer');
|
||
return !stdout.trim();
|
||
} catch {
|
||
// pgrep returns non-zero if no process found
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Check macOS Accessibility permission without Electron.
|
||
* Uses AppleScript to test if we can control system events.
|
||
*/
|
||
private async checkMacOSAccessibility(): Promise<boolean> {
|
||
try {
|
||
// Try to use AppleScript to check accessibility
|
||
// This will fail if accessibility permission is not granted
|
||
await execAsync(`osascript -e 'tell application "System Events" to return name of first process'`);
|
||
return true;
|
||
} catch {
|
||
// Permission denied or System Events not accessible
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check macOS Screen Recording permission without Electron.
|
||
* Uses CGPreflightScreenCaptureAccess (requires native code) or heuristics.
|
||
*/
|
||
private async checkMacOSScreenRecording(): Promise<boolean> {
|
||
try {
|
||
// Use screencapture command with minimal output
|
||
// -c captures to clipboard, -x prevents sound
|
||
// This will succeed even without permission but we can check for errors
|
||
const { stderr } = await execAsync('screencapture -x -c 2>&1 || true');
|
||
|
||
// If there's a permission error in stderr, we don't have permission
|
||
if (stderr.includes('permission') || stderr.includes('denied')) {
|
||
return false;
|
||
}
|
||
|
||
// Additional check: try to use nut.js screen capture
|
||
// This is the most reliable check but may throw
|
||
try {
|
||
const { screen } = await import('@nut-tree-fork/nut-js');
|
||
await screen.width();
|
||
return true;
|
||
} catch (nutError) {
|
||
const errorStr = String(nutError);
|
||
if (errorStr.includes('permission') || errorStr.includes('denied') || errorStr.includes('screen')) {
|
||
return false;
|
||
}
|
||
// Other errors might be unrelated to permissions
|
||
return true;
|
||
}
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear cached permission status.
|
||
*/
|
||
clearCache(): void {
|
||
this.cachedStatus = null;
|
||
}
|
||
|
||
/**
|
||
* Format permission status for logging.
|
||
*/
|
||
formatStatus(status: E2EPermissionStatus): string {
|
||
const lines = [
|
||
`Platform: ${status.platform}`,
|
||
`CI Environment: ${status.isCI ? 'Yes' : 'No'}`,
|
||
`Headless: ${status.isHeadless ? 'Yes' : 'No'}`,
|
||
`Accessibility Permission: ${status.accessibility ? '✓' : '✗'}`,
|
||
`Screen Recording Permission: ${status.screenRecording ? '✓' : '✗'}`,
|
||
`Can Run Real Automation: ${status.canRunRealAutomation ? '✓' : '✗'}`,
|
||
];
|
||
return lines.join('\n');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Singleton instance for use in tests.
|
||
*/
|
||
export const permissionGuard = new PermissionGuard();
|
||
|
||
/**
|
||
* Skip helper for Cucumber tests.
|
||
* Call in Before hook to skip tests if permissions are unavailable.
|
||
*
|
||
* @returns Skip reason if tests should be skipped, undefined otherwise
|
||
*/
|
||
export async function shouldSkipRealAutomationTests(): Promise<string | undefined> {
|
||
const result = await permissionGuard.checkPermissions();
|
||
|
||
if (result.shouldSkip) {
|
||
console.warn('\n⚠️ Skipping real automation tests:');
|
||
console.warn(` ${result.skipReason}`);
|
||
if (result.warnings.length > 0) {
|
||
result.warnings.forEach(w => console.warn(` - ${w}`));
|
||
}
|
||
return result.skipReason;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Assert that real automation can proceed.
|
||
* Throws an error with detailed information if not.
|
||
*/
|
||
export async function assertCanRunRealAutomation(): Promise<void> {
|
||
const result = await permissionGuard.checkPermissions();
|
||
|
||
if (!result.canProceed) {
|
||
const status = permissionGuard.formatStatus(result.status);
|
||
throw new Error(
|
||
`Cannot run real automation tests:\n${result.skipReason}\n\nPermission Status:\n${status}`
|
||
);
|
||
}
|
||
} |