This commit is contained in:
2025-11-26 17:03:29 +01:00
parent ff3528e5ef
commit fef75008d8
147 changed files with 112370 additions and 5162 deletions

View File

@@ -0,0 +1,163 @@
import { test, expect } from '@playwright/test';
import { ElectronTestHarness } from './helpers/electron-test-harness';
import { ConsoleMonitor } from './helpers/console-monitor';
import { IPCVerifier } from './helpers/ipc-verifier';
/**
* Electron App Smoke Test Suite
*
* Purpose: Catch ALL runtime errors before they reach production
*
* Critical Detections:
* 1. Browser context violations (Node.js modules in renderer)
* 2. Console errors during app lifecycle
* 3. IPC channel communication failures
* 4. React rendering failures
*
* RED Phase Expectation:
* This test MUST FAIL due to current browser context errors:
* - "Module 'path' has been externalized for browser compatibility"
* - "ReferenceError: __dirname is not defined"
*/
test.describe('Electron App Smoke Tests', () => {
let harness: ElectronTestHarness;
let monitor: ConsoleMonitor;
test.beforeEach(async () => {
harness = new ElectronTestHarness();
monitor = new ConsoleMonitor();
});
test.afterEach(async () => {
await harness.close();
});
test('should launch Electron app without errors', async () => {
// Given: Fresh Electron app launch
await harness.launch();
const page = harness.getMainWindow();
// When: Monitor console during startup
monitor.startMonitoring(page);
// Wait for app to fully initialize
await page.waitForTimeout(2000);
// Then: No console errors should be present
expect(monitor.hasErrors(), monitor.formatErrors()).toBe(false);
});
test('should render main React UI without browser context errors', async () => {
// Given: Electron app is launched
await harness.launch();
const page = harness.getMainWindow();
monitor.startMonitoring(page);
// When: Waiting for React to render
await page.waitForLoadState('networkidle');
// Then: No browser context errors (externalized modules, __dirname, require)
expect(
monitor.hasBrowserContextErrors(),
'Browser context errors detected - Node.js modules imported in renderer process:\n' +
monitor.formatErrors()
).toBe(false);
// And: React root should be present
const appRoot = await page.locator('#root').count();
expect(appRoot).toBeGreaterThan(0);
});
test('should have functional IPC channels', async () => {
// Given: Electron app is running
await harness.launch();
const page = harness.getMainWindow();
monitor.startMonitoring(page);
// When: Testing core IPC channels
const app = harness.getApp();
const verifier = new IPCVerifier(app);
const results = await verifier.verifyAllChannels();
// Then: All IPC channels should respond
const failedChannels = results.filter(r => !r.success);
expect(
failedChannels.length,
`IPC channels failed:\n${IPCVerifier.formatResults(results)}`
).toBe(0);
// And: No console errors during IPC operations
expect(monitor.hasErrors(), monitor.formatErrors()).toBe(false);
});
test('should handle console errors gracefully', async () => {
// Given: Electron app is launched
await harness.launch();
const page = harness.getMainWindow();
monitor.startMonitoring(page);
// When: App runs through full initialization
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Then: Capture and report any console errors
const errors = monitor.getErrors();
const warnings = monitor.getWarnings();
// This assertion WILL FAIL in RED phase
expect(
errors.length,
`Console errors detected:\n${monitor.formatErrors()}`
).toBe(0);
// Log warnings for visibility (non-blocking)
if (warnings.length > 0) {
console.log('⚠️ Warnings detected:', warnings);
}
});
test('should not have uncaught exceptions during startup', async () => {
// Given: Fresh Electron launch
await harness.launch();
const page = harness.getMainWindow();
// When: Monitor for uncaught exceptions
const uncaughtExceptions: Error[] = [];
page.on('pageerror', (error) => {
uncaughtExceptions.push(error);
});
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);
// Then: No uncaught exceptions
expect(
uncaughtExceptions.length,
`Uncaught exceptions:\n${uncaughtExceptions.map(e => e.message).join('\n')}`
).toBe(0);
});
test('should complete full app lifecycle without crashes', async () => {
// Given: Electron app launches successfully
await harness.launch();
const page = harness.getMainWindow();
monitor.startMonitoring(page);
// When: Running through complete app lifecycle
await page.waitForLoadState('networkidle');
// Simulate user interaction
const appVisible = await page.isVisible('#root');
expect(appVisible).toBe(true);
// Then: No errors throughout lifecycle
expect(monitor.hasErrors(), monitor.formatErrors()).toBe(false);
// And: App can close cleanly
await harness.close();
// Verify clean shutdown (no hanging promises)
expect(monitor.hasErrors()).toBe(false);
});
});

View File

@@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
/**
* Electron Build Smoke Test
*
* Purpose: Detect browser context errors during Electron build
*
* This test catches bundling issues where Node.js modules are imported
* in the renderer process, causing runtime errors.
*
* RED Phase: This test MUST FAIL due to externalized modules
*/
test.describe('Electron Build Smoke Tests', () => {
test('should build Electron app without browser context errors', () => {
// When: Building the Electron companion app
let buildOutput: string;
try {
buildOutput = execSync('npm run companion:build', {
cwd: process.cwd(),
encoding: 'utf-8',
stdio: 'pipe',
});
} catch (error: any) {
buildOutput = error.stdout + error.stderr;
}
// Then: Build should not contain externalized module warnings
const foundErrors: string[] = [];
// Split output into lines and check each line
const lines = buildOutput.split('\n');
lines.forEach(line => {
if (line.includes('has been externalized for browser compatibility')) {
foundErrors.push(line.trim());
}
if (line.includes('Cannot access') && line.includes('in client code')) {
foundErrors.push(line.trim());
}
});
// This WILL FAIL in RED phase due to electron/fs/path being externalized
expect(
foundErrors.length,
`Browser context errors detected during build:\n\n${foundErrors.map((e, i) => `${i + 1}. ${e}`).join('\n')}\n\n` +
`These indicate Node.js modules (electron, fs, path) are being imported in renderer code.\n` +
`This will cause runtime errors when the app launches.`
).toBe(0);
});
test('should not import Node.js modules in renderer source code', () => {
// Given: Renderer source code
const fs = require('fs');
const path = require('path');
const rendererPath = path.join(
process.cwd(),
'apps/companion/renderer'
);
// When: Checking renderer source for forbidden imports
const forbiddenPatterns = [
{ pattern: /from\s+['"]electron['"]/, name: 'electron' },
{ pattern: /require\(['"]electron['"]\)/, name: 'electron' },
{ pattern: /from\s+['"]fs['"]/, name: 'fs' },
{ pattern: /require\(['"]fs['"]\)/, name: 'fs' },
{ pattern: /from\s+['"]path['"]/, name: 'path' },
{ pattern: /require\(['"]path['"]\)/, name: 'path' },
];
const violations: Array<{ file: string; line: number; import: string; module: string }> = [];
function scanDirectory(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
entries.forEach((entry: any) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
scanDirectory(fullPath);
} else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {
const content = fs.readFileSync(fullPath, 'utf-8');
const lines = content.split('\n');
lines.forEach((line, index) => {
forbiddenPatterns.forEach(({ pattern, name }) => {
if (pattern.test(line)) {
violations.push({
file: path.relative(process.cwd(), fullPath),
line: index + 1,
import: line.trim(),
module: name,
});
}
});
});
}
});
}
scanDirectory(rendererPath);
// Then: No Node.js modules should be imported in renderer
expect(
violations.length,
`Found Node.js module imports in renderer source code:\n\n${
violations.map(v => `${v.file}:${v.line}\n Module: ${v.module}\n Code: ${v.import}`).join('\n\n')
}\n\nRenderer code must use the preload script or IPC to access Node.js APIs.`
).toBe(0);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DIContainer } from '../../apps/companion/main/di-container';
import { StartAutomationSessionUseCase } from '../../packages/application/use-cases/StartAutomationSessionUseCase';
import { CheckAuthenticationUseCase } from '../../packages/application/use-cases/CheckAuthenticationUseCase';
import { InitiateLoginUseCase } from '../../packages/application/use-cases/InitiateLoginUseCase';
import { ClearSessionUseCase } from '../../packages/application/use-cases/ClearSessionUseCase';
import { ConfirmCheckoutUseCase } from '../../packages/application/use-cases/ConfirmCheckoutUseCase';
import { PlaywrightAutomationAdapter } from '../../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter';
import { InMemorySessionRepository } from '../../packages/infrastructure/repositories/InMemorySessionRepository';
import { NoOpLogAdapter } from '../../packages/infrastructure/adapters/logging/NoOpLogAdapter';
// Mock Electron's app module
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === 'userData') return '/tmp/test-user-data';
return '/tmp/test';
}),
getAppPath: vi.fn(() => '/tmp/test-app'),
isPackaged: false,
},
}));
describe('Electron DIContainer Smoke Tests', () => {
beforeEach(() => {
DIContainer['instance'] = undefined;
});
it('DIContainer initializes without errors', () => {
expect(() => DIContainer.getInstance()).not.toThrow();
});
it('All use cases are accessible', () => {
const container = DIContainer.getInstance();
expect(() => container.getStartAutomationUseCase()).not.toThrow();
expect(() => container.getCheckAuthenticationUseCase()).not.toThrow();
expect(() => container.getInitiateLoginUseCase()).not.toThrow();
expect(() => container.getClearSessionUseCase()).not.toThrow();
expect(() => container.getConfirmCheckoutUseCase()).not.toThrow();
});
it('Use case instances are available after initialization', () => {
const container = DIContainer.getInstance();
// Verify all core use cases are available
expect(container.getStartAutomationUseCase()).not.toBeNull();
expect(container.getStartAutomationUseCase()).toBeDefined();
// These may be null in test mode, but should not throw
expect(() => container.getCheckAuthenticationUseCase()).not.toThrow();
expect(() => container.getInitiateLoginUseCase()).not.toThrow();
expect(() => container.getClearSessionUseCase()).not.toThrow();
});
it('Container provides access to dependencies', () => {
const container = DIContainer.getInstance();
// Verify core dependencies are accessible
expect(container.getSessionRepository()).toBeDefined();
expect(container.getAutomationEngine()).toBeDefined();
expect(container.getBrowserAutomation()).toBeDefined();
expect(container.getLogger()).toBeDefined();
});
it('ConfirmCheckoutUseCase can be verified without errors', () => {
const container = DIContainer.getInstance();
// This getter should not throw even if null (verifies the import)
expect(() => container.getConfirmCheckoutUseCase()).not.toThrow();
});
});

View File

@@ -0,0 +1,131 @@
import { Page, ConsoleMessage } from '@playwright/test';
export interface ConsoleError {
type: 'error' | 'warning' | 'pageerror';
message: string;
location?: string;
timestamp: Date;
}
/**
* ConsoleMonitor - Aggregates and tracks all console output
*
* Purpose: Catch ANY runtime errors during Electron app lifecycle
*
* Critical Detections:
* - "Module has been externalized for browser compatibility"
* - "__dirname is not defined"
* - "require is not defined"
* - Any uncaught exceptions
*/
export class ConsoleMonitor {
private errors: ConsoleError[] = [];
private warnings: ConsoleError[] = [];
private isMonitoring = false;
/**
* Start monitoring console output on the page
*/
startMonitoring(page: Page): void {
if (this.isMonitoring) {
return;
}
// Monitor console.error calls
page.on('console', (msg: ConsoleMessage) => {
if (msg.type() === 'error') {
this.errors.push({
type: 'error',
message: msg.text(),
location: msg.location()?.url,
timestamp: new Date(),
});
} else if (msg.type() === 'warning') {
this.warnings.push({
type: 'warning',
message: msg.text(),
location: msg.location()?.url,
timestamp: new Date(),
});
}
});
// Monitor uncaught exceptions
page.on('pageerror', (error: Error) => {
this.errors.push({
type: 'pageerror',
message: error.message,
location: error.stack,
timestamp: new Date(),
});
});
this.isMonitoring = true;
}
/**
* Check if any errors were detected
*/
hasErrors(): boolean {
return this.errors.length > 0;
}
/**
* Get all detected errors
*/
getErrors(): ConsoleError[] {
return [...this.errors];
}
/**
* Get all detected warnings
*/
getWarnings(): ConsoleError[] {
return [...this.warnings];
}
/**
* Format errors for test output
*/
formatErrors(): string {
if (this.errors.length === 0) {
return 'No errors detected';
}
const lines = ['Console errors detected during test:', ''];
this.errors.forEach((error, index) => {
lines.push(`${index + 1}. [${error.type}] ${error.message}`);
if (error.location) {
lines.push(` Location: ${error.location}`);
}
lines.push('');
});
return lines.join('\n');
}
/**
* Check for specific browser context errors
*/
hasBrowserContextErrors(): boolean {
const contextErrorPatterns = [
/has been externalized for browser compatibility/i,
/__dirname is not defined/i,
/require is not defined/i,
/Cannot access .* in client code/i,
];
return this.errors.some(error =>
contextErrorPatterns.some(pattern => pattern.test(error.message))
);
}
/**
* Reset monitoring state
*/
reset(): void {
this.errors = [];
this.warnings = [];
}
}

View File

@@ -0,0 +1,78 @@
import { _electron as electron, ElectronApplication, Page } from '@playwright/test';
import * as path from 'path';
/**
* ElectronTestHarness - Manages Electron app lifecycle for smoke tests
*
* Responsibilities:
* - Launch actual compiled Electron app
* - Wait for renderer window to open
* - Provide access to main process and renderer page
* - Clean shutdown
*/
export class ElectronTestHarness {
private app: ElectronApplication | null = null;
private mainWindow: Page | null = null;
/**
* Launch Electron app and wait for main window
*
* @throws Error if app fails to launch or window doesn't open
*/
async launch(): Promise<void> {
// Path to the built Electron app entry point
const electronEntryPath = path.join(__dirname, '../../../apps/companion/dist/main/main.cjs');
// Launch Electron app with the compiled entry file
// Note: Playwright may have compatibility issues with certain Electron versions
// regarding --remote-debugging-port flag
this.app = await electron.launch({
args: [electronEntryPath],
env: {
...process.env,
NODE_ENV: 'test',
},
// Try to disable Chrome DevTools Protocol features that might conflict
executablePath: process.env.ELECTRON_EXECUTABLE_PATH,
});
// Wait for first window (renderer process)
this.mainWindow = await this.app.firstWindow({
timeout: 10_000,
});
// Wait for React to render
await this.mainWindow.waitForLoadState('domcontentloaded');
}
/**
* Get the main renderer window
*/
getMainWindow(): Page {
if (!this.mainWindow) {
throw new Error('Main window not available. Did you call launch()?');
}
return this.mainWindow;
}
/**
* Get the Electron app instance for IPC testing
*/
getApp(): ElectronApplication {
if (!this.app) {
throw new Error('Electron app not available. Did you call launch()?');
}
return this.app;
}
/**
* Clean shutdown of Electron app
*/
async close(): Promise<void> {
if (this.app) {
await this.app.close();
this.app = null;
this.mainWindow = null;
}
}
}

View File

@@ -0,0 +1,159 @@
import { ElectronApplication } from '@playwright/test';
export interface IPCTestResult {
channel: string;
success: boolean;
error?: string;
duration: number;
}
/**
* IPCVerifier - Tests IPC channel contracts
*
* Purpose: Verify main <-> renderer communication works
* Scope: Core IPC channels required for app functionality
*/
export class IPCVerifier {
constructor(private app: ElectronApplication) {}
/**
* Test checkAuth IPC channel
*/
async testCheckAuth(): Promise<IPCTestResult> {
const start = Date.now();
const channel = 'checkAuth';
try {
const result = await this.app.evaluate(async ({ ipcMain }) => {
return new Promise((resolve) => {
// Simulate IPC call
const mockEvent = { reply: (ch: string, data: any) => resolve(data) } as any;
const handler = (ipcMain as any).listeners('checkAuth')[0];
if (!handler) {
resolve({ error: 'Handler not registered' });
} else {
handler(mockEvent);
}
});
});
return {
channel,
success: !result.error,
error: result.error,
duration: Date.now() - start,
};
} catch (error) {
return {
channel,
success: false,
error: error instanceof Error ? error.message : String(error),
duration: Date.now() - start,
};
}
}
/**
* Test getBrowserMode IPC channel
*/
async testGetBrowserMode(): Promise<IPCTestResult> {
const start = Date.now();
const channel = 'getBrowserMode';
try {
const result = await this.app.evaluate(async ({ ipcMain }) => {
return new Promise((resolve) => {
const mockEvent = { reply: (ch: string, data: any) => resolve(data) } as any;
const handler = (ipcMain as any).listeners('getBrowserMode')[0];
if (!handler) {
resolve({ error: 'Handler not registered' });
} else {
handler(mockEvent);
}
});
});
return {
channel,
success: typeof result === 'boolean' || !result.error,
error: result.error,
duration: Date.now() - start,
};
} catch (error) {
return {
channel,
success: false,
error: error instanceof Error ? error.message : String(error),
duration: Date.now() - start,
};
}
}
/**
* Test startAutomationSession IPC channel contract
*/
async testStartAutomationSession(): Promise<IPCTestResult> {
const start = Date.now();
const channel = 'startAutomationSession';
try {
const result = await this.app.evaluate(async ({ ipcMain }) => {
return new Promise((resolve) => {
const mockEvent = { reply: (ch: string, data: any) => resolve(data) } as any;
const handler = (ipcMain as any).listeners('startAutomationSession')[0];
if (!handler) {
resolve({ error: 'Handler not registered' });
} else {
// Test with mock data
handler(mockEvent, { mode: 'test' });
}
});
});
return {
channel,
success: !result.error,
error: result.error,
duration: Date.now() - start,
};
} catch (error) {
return {
channel,
success: false,
error: error instanceof Error ? error.message : String(error),
duration: Date.now() - start,
};
}
}
/**
* Run all IPC tests and return results
*/
async verifyAllChannels(): Promise<IPCTestResult[]> {
return Promise.all([
this.testCheckAuth(),
this.testGetBrowserMode(),
this.testStartAutomationSession(),
]);
}
/**
* Format IPC test results for output
*/
static formatResults(results: IPCTestResult[]): string {
const lines = ['IPC Channel Verification:', ''];
results.forEach(result => {
const status = result.success ? '✓' : '✗';
lines.push(`${status} ${result.channel} (${result.duration}ms)`);
if (result.error) {
lines.push(` Error: ${result.error}`);
}
});
return lines.join('\n');
}
}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, afterEach } from 'vitest';
import { PlaywrightAutomationAdapter } from '../../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter';
import { FixtureServer } from '../../packages/infrastructure/adapters/automation/FixtureServer';
describe('Playwright Adapter Smoke Tests', () => {
let adapter: PlaywrightAutomationAdapter | undefined;
let server: FixtureServer | undefined;
afterEach(async () => {
if (adapter) {
try {
await adapter.disconnect();
} catch {
// Ignore cleanup errors
}
adapter = undefined;
}
if (server) {
try {
await server.stop();
} catch {
// Ignore cleanup errors
}
server = undefined;
}
});
it('Adapter instantiates without errors', () => {
expect(() => {
adapter = new PlaywrightAutomationAdapter({
headless: true,
mode: 'mock',
timeout: 5000,
});
}).not.toThrow();
});
it('Browser connects successfully', async () => {
adapter = new PlaywrightAutomationAdapter({
headless: true,
mode: 'mock',
timeout: 5000,
});
const result = await adapter.connect();
expect(result.success).toBe(true);
expect(adapter.isConnected()).toBe(true);
});
it('Basic navigation works with mock fixtures', async () => {
server = new FixtureServer();
await server.start();
adapter = new PlaywrightAutomationAdapter({
headless: true,
mode: 'mock',
timeout: 5000,
});
await adapter.connect();
const navResult = await adapter.navigateToPage(server.getFixtureUrl(2));
expect(navResult.success).toBe(true);
});
it('Adapter can be instantiated multiple times', () => {
expect(() => {
const adapter1 = new PlaywrightAutomationAdapter({
headless: true,
mode: 'mock',
timeout: 5000,
});
const adapter2 = new PlaywrightAutomationAdapter({
headless: true,
mode: 'mock',
timeout: 5000,
});
expect(adapter1).not.toBe(adapter2);
}).not.toThrow();
});
it('FixtureServer starts and stops cleanly', async () => {
server = new FixtureServer();
await expect(server.start()).resolves.not.toThrow();
expect(server.getFixtureUrl(2)).toContain('http://localhost:');
await expect(server.stop()).resolves.not.toThrow();
});
});