working companion prototype
This commit is contained in:
301
tests/e2e/support/PermissionGuard.ts
Normal file
301
tests/e2e/support/PermissionGuard.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user