working companion prototype

This commit is contained in:
2025-11-24 23:32:36 +01:00
parent e7978024d7
commit e2bea9a126
175 changed files with 23227 additions and 3519 deletions

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