feat(companion): implement hosted session automation POC with TDD approach

This commit is contained in:
2025-11-21 16:27:15 +01:00
parent 7a3562a844
commit 098bfc2c11
26 changed files with 6469 additions and 0 deletions

View File

@@ -0,0 +1,365 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { InMemorySessionRepository } from '../../../src/infrastructure/repositories/InMemorySessionRepository';
import { AutomationSession } from '../../../src/packages/domain/entities/AutomationSession';
import { StepId } from '../../../src/packages/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

@@ -0,0 +1,282 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MockBrowserAutomationAdapter } from '../../../src/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
import { StepId } from '../../../src/packages/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.simulatedDelay).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.simulatedDelay).toBeGreaterThanOrEqual(200);
expect(result.simulatedDelay).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.value).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.simulatedDelay).toBeGreaterThan(0);
});
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.value).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.selector).toBe(selector);
});
it('should simulate click delays', async () => {
const selector = '#submit-button';
const result = await adapter.clickElement(selector);
expect(result.simulatedDelay).toBeGreaterThan(0);
expect(result.simulatedDelay).toBeLessThanOrEqual(300);
});
});
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.selector).toBe(selector);
});
it('should simulate element load time', async () => {
const selector = '.loading-spinner';
const result = await adapter.waitForElement(selector);
expect(result.simulatedDelay).toBeGreaterThanOrEqual(100);
expect(result.simulatedDelay).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.simulatedDelay).toBeGreaterThanOrEqual(200);
expect(result.simulatedDelay).toBeLessThanOrEqual(600);
});
});
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.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.stepId).toBe(6);
expect(result.wasModalStep).toBe(true);
});
it('should execute step 18 (final step)', async () => {
const stepId = StepId.create(18);
const config = {
sessionName: 'Test Race',
trackId: 'spa',
carIds: ['dallara-f3'],
};
const result = await adapter.executeStep(stepId, config);
expect(result.success).toBe(true);
expect(result.stepId).toBe(18);
expect(result.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.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.metrics).toBeDefined();
expect(result.metrics.totalDelay).toBeGreaterThan(0);
expect(result.metrics.operationCount).toBeGreaterThan(0);
});
});
});