Files
gridpilot.gg/tests/e2e/support/PermissionGuard.ts

301 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`
);
}
}