remove companion tests

This commit is contained in:
2026-01-03 15:18:40 +01:00
parent 20f1b53c27
commit afbe42b0e1
67 changed files with 72 additions and 6325 deletions

View File

@@ -1,361 +0,0 @@
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();
});
});
});

View File

@@ -1,378 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Result } from '@gridpilot/shared/application/Result';
import { CheckoutPriceExtractor } from '../../../apps/companion/main/automation/infrastructure/automation/CheckoutPriceExtractor';
import { CheckoutStateEnum } from 'apps/companion/main/automation/domain/value-objects/CheckoutState';
/**
* CheckoutPriceExtractor Integration Tests - GREEN PHASE
*
* Tests verify HTML parsing for checkout price extraction and state detection.
*/
type Page = ConstructorParameters<typeof CheckoutPriceExtractor>[0];
type Locator = ReturnType<Page['locator']>;
describe('CheckoutPriceExtractor Integration', () => {
let mockPage: Page;
let mockLocator: any;
let mockPillLocator: any;
beforeEach(() => {
// Create nested locator mock for span.label-pill
mockPillLocator = {
textContent: vi.fn().mockResolvedValue('$0.50'),
first: vi.fn().mockReturnThis(),
locator: vi.fn().mockReturnThis(),
};
mockLocator = {
getAttribute: vi.fn(),
innerHTML: vi.fn(),
textContent: vi.fn(),
locator: vi.fn(() => mockPillLocator),
first: vi.fn().mockReturnThis(),
};
mockPage = {
locator: vi.fn((selector) => {
if (selector === '.label-pill, .label-inverse') {
return mockPillLocator;
}
return mockLocator;
}),
};
});
describe('Success state HTML extraction', () => {
it('should extract $0.50 from success button', async () => {
const buttonHtml = '<a class="btn btn-success"><span class="label label-pill label-inverse">$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price).not.toBeNull();
expect(info.price!.getAmount()).toBe(0.50);
expect(info.state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should extract $5.00 from success button', async () => {
const buttonHtml = '<a class="btn btn-success"><span class="label label-pill label-inverse">$5.00</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$5.00');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price!.getAmount()).toBe(5.00);
expect(info.state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should extract $100.00 from success button', async () => {
const buttonHtml = '<a class="btn btn-success"><span class="label label-pill label-inverse">$100.00</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$100.00');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price!.getAmount()).toBe(100.00);
expect(info.state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should detect READY state from btn-success class', async () => {
const buttonHtml = '<a class="btn btn-success"><span>$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().state.getValue()).toBe(CheckoutStateEnum.READY);
});
});
describe('Insufficient funds HTML detection', () => {
it('should detect INSUFFICIENT_FUNDS when btn-success is missing', async () => {
const buttonHtml = '<a class="btn btn-default"><span class="label label-pill label-inverse">$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-default');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price).not.toBeNull();
expect(info.price!.getAmount()).toBe(0.50);
expect(info.state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('should still extract price when funds are insufficient', async () => {
const buttonHtml = '<a class="btn btn-default"><span>$10.00</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-default');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$10.00');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price!.getAmount()).toBe(10.00);
expect(info.state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('should detect btn-primary as insufficient funds', async () => {
const buttonHtml = '<a class="btn btn-primary"><span>$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-primary');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
});
describe('Price parsing variations', () => {
it('should parse price with nested span tags', async () => {
const buttonHtml = '<a class="btn btn-success"><span class="outer"><span class="inner">$0.50</span></span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().price!.getAmount()).toBe(0.50);
});
it('should parse price with whitespace', async () => {
const buttonHtml = '<a class="btn btn-success"><span> $0.50 </span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue(' $0.50 ');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().price!.getAmount()).toBe(0.50);
});
it('should parse price with multiple classes', async () => {
const buttonHtml = '<a class="btn btn-lg btn-success pull-right"><span class="label label-pill label-inverse">$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-lg btn-success pull-right');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().price!.getAmount()).toBe(0.50);
expect(result.unwrap().state.getValue()).toBe(CheckoutStateEnum.READY);
});
});
describe('Missing button handling', () => {
it('should return UNKNOWN state when button not found', async () => {
mockLocator.getAttribute.mockResolvedValue(null);
mockLocator.innerHTML.mockRejectedValue(new Error('Element not found'));
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price).toBeNull();
expect(info.state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
});
it('should return null price when button not found', async () => {
mockLocator.getAttribute.mockResolvedValue(null);
mockPillLocator.textContent.mockResolvedValue(null);
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().price).toBeNull();
});
});
describe('Malformed HTML handling', () => {
it('should return null price when price text is invalid', async () => {
const buttonHtml = '<a class="btn btn-success"><span>Invalid Price</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('Invalid Price');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price).toBeNull();
expect(info.state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('should return null price when price is missing dollar sign', async () => {
const buttonHtml = '<a class="btn btn-success"><span>0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().price).toBeNull();
});
it('should handle empty price text', async () => {
const buttonHtml = '<a class="btn btn-success"><span></span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().price).toBeNull();
});
});
describe('Button HTML capture', () => {
it('should capture full button HTML for debugging', async () => {
const buttonHtml = '<a class="btn btn-success"><span class="label label-pill label-inverse">$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().buttonHtml).toBe(buttonHtml);
});
it('should capture button HTML even when price parsing fails', async () => {
const buttonHtml = '<a class="btn btn-success"><span>Invalid</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('Invalid');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().buttonHtml).toBe(buttonHtml);
});
it('should return empty buttonHtml when button not found', async () => {
mockLocator.getAttribute.mockResolvedValue(null);
mockLocator.innerHTML.mockResolvedValue('');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().buttonHtml).toBe('');
});
});
describe('BDD Scenarios', () => {
it('Given checkout button with $0.50 and btn-success, When extracting, Then price is $0.50 and state is READY', async () => {
const buttonHtml = '<a class="btn btn-success"><span>$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price!.getAmount()).toBe(0.50);
expect(info.state.getValue()).toBe(CheckoutStateEnum.READY);
});
it('Given checkout button with $0.50 without btn-success, When extracting, Then state is INSUFFICIENT_FUNDS', async () => {
const buttonHtml = '<a class="btn btn-default"><span>$0.50</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-default');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('$0.50');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
expect(result.unwrap().state.getValue()).toBe(CheckoutStateEnum.INSUFFICIENT_FUNDS);
});
it('Given button not found, When extracting, Then state is UNKNOWN and price is null', async () => {
mockLocator.getAttribute.mockResolvedValue(null);
mockLocator.innerHTML.mockResolvedValue('');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price).toBeNull();
expect(info.state.getValue()).toBe(CheckoutStateEnum.UNKNOWN);
});
it('Given malformed price text, When extracting, Then price is null but state is detected', async () => {
const buttonHtml = '<a class="btn btn-success"><span>Invalid</span></a>';
mockLocator.getAttribute.mockResolvedValue('btn btn-success');
mockLocator.innerHTML.mockResolvedValue(buttonHtml);
mockPillLocator.textContent.mockResolvedValue('Invalid');
const extractor = new CheckoutPriceExtractor(mockPage);
const result = await extractor.extractCheckoutInfo();
expect(result.isOk()).toBe(true);
const info = result.unwrap();
expect(info.price).toBeNull();
expect(info.state.getValue()).toBe(CheckoutStateEnum.READY);
});
});
});

View File

@@ -1,365 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { InMemorySessionRepository } from '../../../apps/companion/main/automation/infrastructure/repositories/InMemorySessionRepository';
import { AutomationSession } from 'apps/companion/main/automation/domain/entities/AutomationSession';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
describe('InMemorySessionRepository Integration Tests', () => {
let repository: InMemorySessionRepository;
beforeEach(() => {
repository = new InMemorySessionRepository();
});
describe('save', () => {
it('should persist a new session', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
const retrieved = await repository.findById(session.id);
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe(session.id);
});
it('should update existing session on duplicate save', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
session.start();
session.transitionToStep(StepId.create(2));
await repository.save(session);
const retrieved = await repository.findById(session.id);
expect(retrieved?.currentStep.value).toBe(2);
expect(retrieved?.state.isInProgress()).toBe(true);
});
it('should preserve all session properties', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race Session',
trackId: 'spa-francorchamps',
carIds: ['dallara-f3', 'porsche-911-gt3'],
});
await repository.save(session);
const retrieved = await repository.findById(session.id);
expect(retrieved?.config.sessionName).toBe('Test Race Session');
expect(retrieved?.config.trackId).toBe('spa-francorchamps');
expect(retrieved?.config.carIds).toEqual(['dallara-f3', 'porsche-911-gt3']);
});
});
describe('findById', () => {
it('should return null for non-existent session', async () => {
const result = await repository.findById('non-existent-id');
expect(result).toBeNull();
});
it('should retrieve existing session by ID', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
const retrieved = await repository.findById(session.id);
expect(retrieved).toBeDefined();
expect(retrieved?.id).toBe(session.id);
});
it('should return domain entity not DTO', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
const retrieved = await repository.findById(session.id);
expect(retrieved).toBeInstanceOf(AutomationSession);
});
it('should retrieve session with correct state', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session.start();
await repository.save(session);
const retrieved = await repository.findById(session.id);
expect(retrieved?.state.isInProgress()).toBe(true);
expect(retrieved?.startedAt).toBeDefined();
});
});
describe('update', () => {
it('should update existing session', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
session.start();
session.transitionToStep(StepId.create(2));
await repository.update(session);
const retrieved = await repository.findById(session.id);
expect(retrieved?.currentStep.value).toBe(2);
});
it('should throw error when updating non-existent session', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await expect(repository.update(session)).rejects.toThrow('Session not found');
});
it('should preserve unchanged properties', async () => {
const session = AutomationSession.create({
sessionName: 'Original Name',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
session.start();
await repository.update(session);
const retrieved = await repository.findById(session.id);
expect(retrieved?.config.sessionName).toBe('Original Name');
expect(retrieved?.state.isInProgress()).toBe(true);
});
it('should update session state correctly', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
session.start();
session.pause();
await repository.update(session);
const retrieved = await repository.findById(session.id);
expect(retrieved?.state.value).toBe('PAUSED');
});
});
describe('delete', () => {
it('should remove session from storage', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
await repository.delete(session.id);
const retrieved = await repository.findById(session.id);
expect(retrieved).toBeNull();
});
it('should not throw when deleting non-existent session', async () => {
await expect(repository.delete('non-existent-id')).resolves.not.toThrow();
});
it('should only delete specified session', async () => {
const session1 = AutomationSession.create({
sessionName: 'Race 1',
trackId: 'spa',
carIds: ['dallara-f3'],
});
const session2 = AutomationSession.create({
sessionName: 'Race 2',
trackId: 'monza',
carIds: ['porsche-911-gt3'],
});
await repository.save(session1);
await repository.save(session2);
await repository.delete(session1.id);
const retrieved1 = await repository.findById(session1.id);
const retrieved2 = await repository.findById(session2.id);
expect(retrieved1).toBeNull();
expect(retrieved2).toBeDefined();
});
});
describe('findAll', () => {
it('should return empty array when no sessions exist', async () => {
const sessions = await repository.findAll();
expect(sessions).toEqual([]);
});
it('should return all saved sessions', async () => {
const session1 = AutomationSession.create({
sessionName: 'Race 1',
trackId: 'spa',
carIds: ['dallara-f3'],
});
const session2 = AutomationSession.create({
sessionName: 'Race 2',
trackId: 'monza',
carIds: ['porsche-911-gt3'],
});
await repository.save(session1);
await repository.save(session2);
const sessions = await repository.findAll();
expect(sessions).toHaveLength(2);
expect(sessions.map(s => s.id)).toContain(session1.id);
expect(sessions.map(s => s.id)).toContain(session2.id);
});
it('should return domain entities not DTOs', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
const sessions = await repository.findAll();
expect(sessions[0]).toBeInstanceOf(AutomationSession);
});
});
describe('findByState', () => {
it('should return sessions matching state', async () => {
const session1 = AutomationSession.create({
sessionName: 'Race 1',
trackId: 'spa',
carIds: ['dallara-f3'],
});
session1.start();
const session2 = AutomationSession.create({
sessionName: 'Race 2',
trackId: 'monza',
carIds: ['porsche-911-gt3'],
});
await repository.save(session1);
await repository.save(session2);
const inProgressSessions = await repository.findByState('IN_PROGRESS');
expect(inProgressSessions).toHaveLength(1);
expect(inProgressSessions[0]!.id).toBe(session1.id);
});
it('should return empty array when no sessions match state', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
const completedSessions = await repository.findByState('COMPLETED');
expect(completedSessions).toEqual([]);
});
it('should handle multiple sessions with same state', async () => {
const session1 = AutomationSession.create({
sessionName: 'Race 1',
trackId: 'spa',
carIds: ['dallara-f3'],
});
const session2 = AutomationSession.create({
sessionName: 'Race 2',
trackId: 'monza',
carIds: ['porsche-911-gt3'],
});
await repository.save(session1);
await repository.save(session2);
const pendingSessions = await repository.findByState('PENDING');
expect(pendingSessions).toHaveLength(2);
});
});
describe('concurrent operations', () => {
it('should handle concurrent saves', async () => {
const sessions = Array.from({ length: 10 }, (_, i) =>
AutomationSession.create({
sessionName: `Race ${i}`,
trackId: 'spa',
carIds: ['dallara-f3'],
})
);
await Promise.all(sessions.map(s => repository.save(s)));
const allSessions = await repository.findAll();
expect(allSessions).toHaveLength(10);
});
it('should handle concurrent updates', async () => {
const session = AutomationSession.create({
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
});
await repository.save(session);
session.start();
await Promise.all([
repository.update(session),
repository.update(session),
repository.update(session),
]);
const retrieved = await repository.findById(session.id);
expect(retrieved?.state.isInProgress()).toBe(true);
});
});
});

View File

@@ -1,282 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MockBrowserAutomationAdapter } from 'core/automation/infrastructure//automation';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
describe('MockBrowserAutomationAdapter Integration Tests', () => {
let adapter: MockBrowserAutomationAdapter;
beforeEach(() => {
adapter = new MockBrowserAutomationAdapter();
});
describe('navigateToPage', () => {
it('should simulate navigation with delay', async () => {
const url = 'https://members.iracing.com/membersite/HostedRacing';
const result = await adapter.navigateToPage(url);
expect(result.success).toBe(true);
expect(result.loadTime).toBeGreaterThan(0);
});
it('should return navigation URL in result', async () => {
const url = 'https://members.iracing.com/membersite/HostedRacing';
const result = await adapter.navigateToPage(url);
expect(result.url).toBe(url);
});
it('should simulate realistic delays', async () => {
const url = 'https://members.iracing.com/membersite/HostedRacing';
const result = await adapter.navigateToPage(url);
expect(result.loadTime).toBeGreaterThanOrEqual(200);
expect(result.loadTime).toBeLessThanOrEqual(800);
});
});
describe('fillFormField', () => {
it('should simulate form field fill with delay', async () => {
const fieldName = 'session-name';
const value = 'Test Race Session';
const result = await adapter.fillFormField(fieldName, value);
expect(result.success).toBe(true);
expect(result.fieldName).toBe(fieldName);
expect(result.valueSet).toBe(value);
});
it('should simulate typing speed delay', async () => {
const fieldName = 'session-name';
const value = 'A'.repeat(50);
const result = await adapter.fillFormField(fieldName, value);
expect(result.valueSet).toBeDefined();
});
it('should handle empty field values', async () => {
const fieldName = 'session-name';
const value = '';
const result = await adapter.fillFormField(fieldName, value);
expect(result.success).toBe(true);
expect(result.valueSet).toBe('');
});
});
describe('clickElement', () => {
it('should simulate button click with delay', async () => {
const selector = '#create-session-button';
const result = await adapter.clickElement(selector);
expect(result.success).toBe(true);
expect(result.target).toBe(selector);
});
it('should simulate click delays', async () => {
const selector = '#submit-button';
const result = await adapter.clickElement(selector);
expect(result.target).toBeDefined();
});
});
describe('waitForElement', () => {
it('should simulate waiting for element to appear', async () => {
const selector = '.modal-dialog';
const result = await adapter.waitForElement(selector);
expect(result.success).toBe(true);
expect(result.target).toBe(selector);
});
it('should simulate element load time', async () => {
const selector = '.loading-spinner';
const result = await adapter.waitForElement(selector);
expect(result.waitedMs).toBeGreaterThanOrEqual(100);
expect(result.waitedMs).toBeLessThanOrEqual(1000);
});
it('should timeout after maximum wait time', async () => {
const selector = '.non-existent-element';
const maxWaitMs = 5000;
const result = await adapter.waitForElement(selector, maxWaitMs);
expect(result.success).toBe(true);
});
});
describe('handleModal', () => {
it('should simulate modal handling for step 6', async () => {
const stepId = StepId.create(6);
const action = 'close';
const result = await adapter.handleModal(stepId, action);
expect(result.success).toBe(true);
expect(result.stepId).toBe(6);
expect(result.action).toBe(action);
});
it('should simulate modal handling for step 9', async () => {
const stepId = StepId.create(9);
const action = 'confirm';
const result = await adapter.handleModal(stepId, action);
expect(result.success).toBe(true);
expect(result.stepId).toBe(9);
});
it('should simulate modal handling for step 12', async () => {
const stepId = StepId.create(12);
const action = 'select';
const result = await adapter.handleModal(stepId, action);
expect(result.success).toBe(true);
expect(result.stepId).toBe(12);
});
it('should throw error for non-modal steps', async () => {
const stepId = StepId.create(1);
const action = 'close';
await expect(adapter.handleModal(stepId, action)).rejects.toThrow(
'Step 1 is not a modal step'
);
});
it('should simulate modal interaction delays', async () => {
const stepId = StepId.create(6);
const action = 'close';
const result = await adapter.handleModal(stepId, action);
expect(result.success).toBe(true);
expect(result.stepId).toBe(6);
expect(result.action).toBe(action);
});
});
describe('executeStep', () => {
it('should execute step 1 (navigation)', async () => {
const stepId = StepId.create(1);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapter.executeStep(stepId, config);
expect(result.success).toBe(true);
expect(result.metadata?.stepId).toBe(1);
});
it('should execute step 6 (modal step)', async () => {
const stepId = StepId.create(6);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapter.executeStep(stepId, config);
expect(result.success).toBe(true);
expect(result.metadata?.stepId).toBe(6);
expect(result.metadata?.wasModalStep).toBe(true);
});
it('should execute step 17 (final step)', async () => {
const stepId = StepId.create(17);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapter.executeStep(stepId, config);
expect(result.success).toBe(true);
expect(result.metadata?.stepId).toBe(17);
expect(result.metadata?.shouldStop).toBe(true);
});
it('should simulate realistic step execution times', async () => {
const stepId = StepId.create(5);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapter.executeStep(stepId, config);
expect(result.metadata?.executionTime).toBeGreaterThan(0);
});
});
describe('error simulation', () => {
it('should simulate random failures when enabled', async () => {
const adapterWithFailures = new MockBrowserAutomationAdapter({
simulateFailures: true,
failureRate: 1.0, // Always fail
});
const stepId = StepId.create(5);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
await expect(adapterWithFailures.executeStep(stepId, config)).rejects.toThrow();
});
it('should not fail when failure simulation disabled', async () => {
const adapterNoFailures = new MockBrowserAutomationAdapter({
simulateFailures: false,
});
const stepId = StepId.create(5);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapterNoFailures.executeStep(stepId, config);
expect(result.success).toBe(true);
});
});
describe('performance metrics', () => {
it('should track operation metrics', async () => {
const stepId = StepId.create(1);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapter.executeStep(stepId, config);
expect(result.metadata).toBeDefined();
expect(result.metadata?.totalDelay).toBeGreaterThan(0);
expect(result.metadata?.operationCount).toBeGreaterThan(0);
});
});
});

View File

@@ -1,121 +0,0 @@
import { describe, it, expect } from 'vitest';
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService';
import type { AutomationEvent } from 'apps/companion/main/automation/application/ports/IAutomationEventPublisher';
import type {
IAutomationLifecycleEmitter,
LifecycleCallback,
} from 'core/automation/infrastructure//IAutomationLifecycleEmitter';
import type {
OverlayAction,
ActionAck,
} from 'apps/companion/main/automation/application/ports/IOverlaySyncPort';
class TestLifecycleEmitter implements IAutomationLifecycleEmitter {
private callbacks: Set<LifecycleCallback> = new Set();
onLifecycle(cb: LifecycleCallback): void {
this.callbacks.add(cb);
}
offLifecycle(cb: LifecycleCallback): void {
this.callbacks.delete(cb);
}
async emit(event: AutomationEvent): Promise<void> {
for (const cb of Array.from(this.callbacks)) {
await cb(event);
}
}
}
class RecordingPublisher {
public events: AutomationEvent[] = [];
async publish(event: AutomationEvent): Promise<void> {
this.events.push(event);
}
}
describe('Overlay lifecycle (integration)', () => {
it('emits modal-opened and confirms after action-started in sane order', async () => {
const lifecycleEmitter = new TestLifecycleEmitter();
const publisher = new RecordingPublisher();
type LoggerLike = {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
fatal: (...args: unknown[]) => void;
child: (...args: unknown[]) => LoggerLike;
flush: (...args: unknown[]) => Promise<void>;
};
const logger = console as unknown as LoggerLike;
const service = new OverlaySyncService({
lifecycleEmitter,
publisher,
logger,
defaultTimeoutMs: 1_000,
});
const action: OverlayAction = {
id: 'hosted-session',
label: 'Starting hosted session',
};
const ackPromise: Promise<ActionAck> = service.startAction(action);
expect(publisher.events.length).toBe(1);
const first = publisher.events[0]!;
expect(first.type).toBe('modal-opened');
expect(first.actionId).toBe('hosted-session');
await lifecycleEmitter.emit({
type: 'panel-attached',
actionId: 'hosted-session',
timestamp: Date.now(),
payload: { selector: '#gridpilot-overlay' },
});
await lifecycleEmitter.emit({
type: 'action-started',
actionId: 'hosted-session',
timestamp: Date.now(),
});
const ack = await ackPromise;
expect(ack.id).toBe('hosted-session');
expect(ack.status).toBe('confirmed');
expect(publisher.events[0]!.type).toBe('modal-opened');
expect(publisher.events[0]!.actionId).toBe('hosted-session');
});
it('emits panel-missing when cancelAction is called', async () => {
const lifecycleEmitter = new TestLifecycleEmitter();
const publisher = new RecordingPublisher();
type LoggerLike = {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
fatal: (...args: unknown[]) => void;
child: (...args: unknown[]) => LoggerLike;
flush: (...args: unknown[]) => Promise<void>;
};
const logger = console as unknown as LoggerLike;
const service = new OverlaySyncService({
lifecycleEmitter,
publisher,
logger,
});
await service.cancelAction('hosted-session-cancel');
expect(publisher.events.length).toBe(1);
const ev = publisher.events[0]!;
expect(ev.type).toBe('panel-missing');
expect(ev.actionId).toBe('hosted-session-cancel');
});
});

View File

@@ -1,108 +0,0 @@
import { describe, it, expect } from 'vitest';
import { PageStateValidator } from 'apps/companion/main/automation/domain/services/PageStateValidator';
import { StepTransitionValidator } from 'apps/companion/main/automation/domain/services/StepTransitionValidator';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
import { SessionState } from 'apps/companion/main/automation/domain/value-objects/SessionState';
describe('Validator conformance (integration)', () => {
describe('PageStateValidator with hosted-session selectors', () => {
it('reports missing DOM markers with descriptive message', () => {
const validator = new PageStateValidator();
const actualState = (selector: string) => {
return selector === '#set-cars';
};
const result = validator.validateState(actualState, {
expectedStep: 'track',
requiredSelectors: ['#set-track', '#track-search'],
});
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.expectedStep).toBe('track');
expect(value.missingSelectors).toEqual(['#set-track', '#track-search']);
expect(value.message).toBe(
'Page state mismatch: Expected to be on "track" page but missing required elements',
);
});
it('reports unexpected DOM markers when forbidden selectors are present', () => {
const validator = new PageStateValidator();
const actualState = (selector: string) => {
return ['#set-cars', '#set-track'].includes(selector);
};
const result = validator.validateState(actualState, {
expectedStep: 'cars',
requiredSelectors: ['#set-cars'],
forbiddenSelectors: ['#set-track'],
});
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(value.isValid).toBe(false);
expect(value.expectedStep).toBe('cars');
expect(value.unexpectedSelectors).toEqual(['#set-track']);
expect(value.message).toBe(
'Page state mismatch: Found unexpected elements on "cars" page',
);
});
});
describe('StepTransitionValidator with hosted-session steps', () => {
it('rejects illegal forward jumps with clear error', () => {
const currentStep = StepId.create(3);
const nextStep = StepId.create(9);
const state = SessionState.create('IN_PROGRESS');
const result = StepTransitionValidator.canTransition(
currentStep,
nextStep,
state,
);
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Cannot skip steps - must progress sequentially',
);
});
it('rejects backward jumps with clear error', () => {
const currentStep = StepId.create(11);
const nextStep = StepId.create(8);
const state = SessionState.create('IN_PROGRESS');
const result = StepTransitionValidator.canTransition(
currentStep,
nextStep,
state,
);
expect(result.isValid).toBe(false);
expect(result.error).toBe(
'Cannot move backward - steps must progress forward only',
);
});
it('provides descriptive step descriptions for hosted steps', () => {
const step3 = StepTransitionValidator.getStepDescription(
StepId.create(3),
);
const step11 = StepTransitionValidator.getStepDescription(
StepId.create(11),
);
const finalStep = StepTransitionValidator.getStepDescription(
StepId.create(17),
);
expect(step3).toBe('Fill Race Information');
expect(step11).toBe('Set Track');
expect(finalStep).toBe(
'Track Conditions (STOP - Manual Submit Required)',
);
});
});
});