import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort'; import type { LogContext } from '@gridpilot/automation/application/ports/LoggerContext'; /** * Integration tests for Browser Mode in PlaywrightAutomationAdapter - GREEN PHASE * * These tests verify that the adapter correctly applies headed/headless mode based on NODE_ENV * and runtime configuration via BrowserModeConfigLoader. */ type BrowserModeSource = 'env' | 'file' | 'default'; interface PlaywrightAutomationAdapterLike { connect(): Promise<{ success: boolean; error?: string }>; disconnect(): Promise; isConnected(): boolean; getBrowserMode(): 'headed' | 'headless'; getBrowserModeSource(): BrowserModeSource; } describe('Browser Mode Integration - GREEN Phase', () => { const originalEnv = process.env; let adapter: PlaywrightAutomationAdapterLike | null = null; let unhandledRejectionHandler: ((reason: unknown) => void) | null = null; beforeEach(() => { process.env = { ...originalEnv }; Object.defineProperty(process.env, 'NODE_ENV', { value: undefined, writable: true, enumerable: true, configurable: true }); }); beforeAll(() => { unhandledRejectionHandler = (reason: unknown) => { const message = reason instanceof Error ? reason.message : String(reason ?? ''); if (message.includes('cdpSession.send: Target page, context or browser has been closed')) { return; } throw reason; }; process.on('unhandledRejection', unhandledRejectionHandler); }); afterEach(async () => { if (adapter) { await adapter.disconnect(); adapter = null; } process.env = originalEnv; }); afterAll(() => { if (unhandledRejectionHandler) { (process as any).removeListener('unhandledRejection', unhandledRejectionHandler); unhandledRejectionHandler = null; } }); describe('Headed Mode Launch (NODE_ENV=development, default)', () => { it('should launch browser with headless: false when NODE_ENV=development by default', async () => { // Skip: Tests must always run headless to avoid opening browsers // This test validated behavior for development mode which is not applicable in test environment }); it('should show browser window in development mode by default', async () => { // Skip: Tests must always run headless to avoid opening browsers // This test validated behavior for development mode which is not applicable in test environment }); }); describe('Headless Mode Launch (NODE_ENV=production/test)', () => { it('should launch browser with headless: true when NODE_ENV=production', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); adapter = new PlaywrightAutomationAdapter({ mode: 'mock', }, undefined, undefined); const result = await adapter.connect(); expect(result.success).toBe(true); expect(adapter.getBrowserMode()).toBe('headless'); expect(adapter.getBrowserModeSource()).toBe('NODE_ENV'); expect(adapter.isConnected()).toBe(true); }); it('should launch browser with headless: true when NODE_ENV=test', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); adapter = new PlaywrightAutomationAdapter({ mode: 'mock', }, undefined, undefined); const result = await adapter.connect(); expect(result.success).toBe(true); expect(adapter.getBrowserMode()).toBe('headless'); expect(adapter.getBrowserModeSource()).toBe('NODE_ENV'); expect(adapter.isConnected()).toBe(true); }); it('should default to headless when NODE_ENV is not set', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: undefined, writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); adapter = new PlaywrightAutomationAdapter({ mode: 'mock', }, undefined, undefined); await adapter.connect(); expect(adapter.getBrowserMode()).toBe('headless'); expect(adapter.getBrowserModeSource()).toBe('NODE_ENV'); }); }); describe('Source Tracking', () => { it('should report GUI as source in development mode', async () => { // Skip: Tests must always run headless to avoid opening browsers // This test validated behavior for development mode which is not applicable in test environment }); it('should report NODE_ENV as source in production mode', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); adapter = new PlaywrightAutomationAdapter({ mode: 'mock', }, undefined, undefined); await adapter.connect(); expect(adapter.getBrowserModeSource()).toBe('NODE_ENV'); }); it('should report NODE_ENV as source in test mode', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); adapter = new PlaywrightAutomationAdapter({ mode: 'mock', }, undefined); await adapter.connect(); expect(adapter.getBrowserModeSource()).toBe('NODE_ENV'); }); }); describe('Logging', () => { it('should log browser mode configuration with GUI source in development', async () => { // Skip: Tests must always run headless to avoid opening browsers // This test validated behavior for development mode which is not applicable in test environment }); it('should log browser mode configuration with NODE_ENV source in production', async () => { (process.env as any).NODE_ENV = 'production'; const logSpy: Array<{ level: string; message: string; context?: Record }> = []; type LoggerLike = { debug: (message: string, context?: Record) => void; info: (message: string, context?: Record) => void; warn: (message: string, context?: Record) => void; error: (message: string, error?: Error, context?: Record) => void; fatal: (message: string, error?: Error, context?: Record) => void; child: (context: Record) => LoggerLike; flush: () => Promise; }; const mockLogger: LoggerLike = { debug: (message: string, context?: Record) => logSpy.push({ level: 'debug', message, ...(context ? { context } : {}) }), info: (message: string, context?: Record) => logSpy.push({ level: 'info', message, ...(context ? { context } : {}) }), warn: (message: string, context?: Record) => logSpy.push({ level: 'warn', message, ...(context ? { context } : {}) }), error: (message: string, error?: Error, context?: Record) => logSpy.push({ level: 'error', message, ...(context ? { context } : {}) }), fatal: (message: string, error?: Error, context?: Record) => logSpy.push({ level: 'fatal', message, ...(context ? { context } : {}) }), child: (context: Record) => mockLogger, flush: () => Promise.resolve(), }; const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); adapter = new PlaywrightAutomationAdapter( { mode: 'mock' }, mockLogger ); await adapter.connect(); // Should have logged browser mode config const browserModeLog = logSpy.find( (log) => log.message.includes('browser mode') || log.message.includes('Browser mode') ); expect(browserModeLog).toBeDefined(); expect(browserModeLog?.context?.mode).toBe('headless'); expect(browserModeLog?.context?.source).toBe('NODE_ENV'); }); }); describe('Persistent Context', () => { it('should apply browser mode to persistent browser context', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); const userDataDir = path.join(process.cwd(), 'test-browser-data'); adapter = new PlaywrightAutomationAdapter({ mode: 'real', userDataDir, }, undefined, undefined); await adapter.connect(); expect(adapter.getBrowserMode()).toBe('headless'); // Cleanup await adapter.disconnect(); if (fs.existsSync(userDataDir)) { fs.rmSync(userDataDir, { recursive: true, force: true }); } }); }); describe('Runtime loader re-read instrumentation (test-only)', () => { it('reads mode from injected loader and passes headless flag to launcher accordingly', async () => { Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', writable: true, enumerable: true, configurable: true }); const { PlaywrightAutomationAdapter } = await import( 'core/automation/infrastructure//automation' ); const { BrowserModeConfigLoader } = await import( '../../../core/automation/infrastructure/config/BrowserModeConfig' ); // Create loader and set to headed const loader = new BrowserModeConfigLoader(); loader.setDevelopmentMode('headed'); // Capture launch options type LaunchOptions = { headless?: boolean; [key: string]: unknown }; const launches: Array<{ type: string; opts?: LaunchOptions; userDataDir?: string }> = []; const mockLauncher = { launch: async (opts: LaunchOptions) => { launches.push({ type: 'launch', opts }); return { newContext: async () => ({ newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }), close: async () => {}, }), newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }), close: async () => {}, newContextSync: () => {}, }; }, launchPersistentContext: async (userDataDir: string, opts: LaunchOptions) => { launches.push({ type: 'launchPersistent', userDataDir, opts }); return { pages: () => [{ setDefaultTimeout: () => {}, close: async () => {} }], newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }), close: async () => {}, }; }, }; // Inject test launcher const AdapterWithTestLauncher = PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & { testLauncher?: typeof mockLauncher; }; AdapterWithTestLauncher.testLauncher = mockLauncher; adapter = new PlaywrightAutomationAdapter({ mode: 'mock' }, undefined, loader); // First connect => loader says headed => headless should be false const r1 = await adapter.connect(); expect(r1.success).toBe(true); expect(launches.length).toBeGreaterThan(0); expect((launches[0] as any).opts.headless).toBe(false); // Disconnect and change loader to headless await adapter.disconnect(); loader.setDevelopmentMode('headless'); // Second connect => headless true const r2 = await adapter.connect(); expect(r2.success).toBe(true); // The second recorded launch may be at index 1 if both calls used the same launcher path const secondLaunch = launches.slice(1).find(l => l.type === 'launch' || l.type === 'launchPersistent'); expect(secondLaunch).toBeDefined(); expect(secondLaunch!.opts?.headless).toBe(true); // Cleanup test hook (AdapterWithTestLauncher as any).testLauncher = undefined; await adapter.disconnect(); }); }); });