361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import type { LoggerPort } from 'apps/companion/main/automation/application/ports/LoggerPort';
|
|
import type { LogContext } from 'apps/companion/main/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<void>;
|
|
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<string, unknown> }> = [];
|
|
type LoggerLike = {
|
|
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
info: (message: string, context?: Record<string, unknown>) => void;
|
|
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
error: (message: string, error?: Error, context?: Record<string, unknown>) => void;
|
|
fatal: (message: string, error?: Error, context?: Record<string, unknown>) => void;
|
|
child: (context: Record<string, unknown>) => LoggerLike;
|
|
flush: () => Promise<void>;
|
|
};
|
|
const mockLogger: LoggerLike = {
|
|
debug: (message: string, context?: Record<string, unknown>) => logSpy.push({ level: 'debug', message, ...(context ? { context } : {}) }),
|
|
info: (message: string, context?: Record<string, unknown>) => logSpy.push({ level: 'info', message, ...(context ? { context } : {}) }),
|
|
warn: (message: string, context?: Record<string, unknown>) => logSpy.push({ level: 'warn', message, ...(context ? { context } : {}) }),
|
|
error: (message: string, error?: Error, context?: Record<string, unknown>) => logSpy.push({ level: 'error', message, ...(context ? { context } : {}) }),
|
|
fatal: (message: string, error?: Error, context?: Record<string, unknown>) => logSpy.push({ level: 'fatal', message, ...(context ? { context } : {}) }),
|
|
child: (context: Record<string, unknown>) => 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(
|
|
'../../../apps/companion/main/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();
|
|
});
|
|
});
|
|
}); |