remove companion tests
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
Feature: Race Event Performance Summary Notifications
|
||||
|
||||
As a driver
|
||||
I want to receive performance summary notifications after races
|
||||
So that I can see my results and rating changes immediately
|
||||
|
||||
Background:
|
||||
Given a league exists with stewarding configuration
|
||||
And a season exists for that league
|
||||
And a race event is scheduled with practice, qualifying, and main race sessions
|
||||
|
||||
Scenario: Driver receives performance summary after main race completion
|
||||
Given I am a registered driver for the race event
|
||||
And all sessions are scheduled
|
||||
When the main race session is completed
|
||||
Then a MainRaceCompleted domain event is published
|
||||
And I receive a race_performance_summary notification
|
||||
And the notification shows my position, incidents, and provisional rating change
|
||||
And the notification has modal urgency and requires no response
|
||||
|
||||
Scenario: Driver receives final results after stewarding closes
|
||||
Given I am a registered driver for the race event
|
||||
And the main race has been completed
|
||||
And the race event is in awaiting_stewarding status
|
||||
When the stewarding window expires
|
||||
Then a RaceEventStewardingClosed domain event is published
|
||||
And I receive a race_final_results notification
|
||||
And the notification shows my final position and rating change
|
||||
And the notification indicates if penalties were applied
|
||||
|
||||
Scenario: Practice and qualifying sessions don't trigger notifications
|
||||
Given I am a registered driver for the race event
|
||||
When practice and qualifying sessions are completed
|
||||
Then no performance summary notifications are sent
|
||||
And the race event status remains in_progress
|
||||
|
||||
Scenario: Only main race completion triggers performance summary
|
||||
Given I am a registered driver for the race event
|
||||
And the race event has practice, qualifying, sprint, and main race sessions
|
||||
When the sprint race session is completed
|
||||
Then no performance summary notification is sent
|
||||
When the main race session is completed
|
||||
Then a performance summary notification is sent
|
||||
|
||||
Scenario: Provisional rating changes are calculated correctly
|
||||
Given I finished in position 1 with 0 incidents
|
||||
When the main race is completed
|
||||
Then my provisional rating change should be +25 points
|
||||
And the notification should display "+25 rating"
|
||||
|
||||
Scenario: Rating penalties are applied for incidents
|
||||
Given I finished in position 5 with 3 incidents
|
||||
When the main race is completed
|
||||
Then my provisional rating change should be reduced by 15 points
|
||||
And the notification should show the adjusted rating change
|
||||
|
||||
Scenario: DNF results show appropriate rating penalty
|
||||
Given I did not finish the race (DNF)
|
||||
When the main race is completed
|
||||
Then my provisional rating change should be -10 points
|
||||
And the notification should display "DNF" as position
|
||||
|
||||
Scenario: Stewarding close mechanism works correctly
|
||||
Given a race event is awaiting_stewarding
|
||||
And the stewarding window is configured for 24 hours
|
||||
When 24 hours have passed since the main race completion
|
||||
Then the CloseRaceEventStewardingUseCase should close the event
|
||||
And final results notifications should be sent to all participants
|
||||
|
||||
Scenario: Race event lifecycle transitions work correctly
|
||||
Given a race event is scheduled
|
||||
When practice and qualifying sessions start
|
||||
Then the race event status becomes in_progress
|
||||
When the main race completes
|
||||
Then the race event status becomes awaiting_stewarding
|
||||
When stewarding closes
|
||||
Then the race event status becomes closed
|
||||
|
||||
Scenario: Notifications include proper action buttons
|
||||
Given I receive a performance summary notification
|
||||
Then it should have a "View Full Results" action button
|
||||
And clicking it should navigate to the race results page
|
||||
|
||||
Scenario: Final results notifications include championship standings link
|
||||
Given I receive a final results notification
|
||||
Then it should have a "View Championship Standings" action button
|
||||
And clicking it should navigate to the league standings page
|
||||
|
||||
Scenario: Notifications are sent to all registered drivers
|
||||
Given 10 drivers are registered for the race event
|
||||
When the main race is completed
|
||||
Then 10 performance summary notifications should be sent
|
||||
When stewarding closes
|
||||
Then 10 final results notifications should be sent
|
||||
|
||||
Scenario: League configuration affects stewarding window
|
||||
Given a league has stewardingClosesHours set to 48
|
||||
When a race event is created for that league
|
||||
Then the stewarding window should be 48 hours after main race completion
|
||||
@@ -1,284 +0,0 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
import { Session } from '@core/racing/domain/entities/Session';
|
||||
import { RaceEvent } from '@core/racing/domain/entities/RaceEvent';
|
||||
import { SessionType } from '@core/racing/domain/value-objects/SessionType';
|
||||
import { MainRaceCompletedEvent } from '@core/racing/domain/events/MainRaceCompleted';
|
||||
import { RaceEventStewardingClosedEvent } from '@core/racing/domain/events/RaceEventStewardingClosed';
|
||||
import { SendPerformanceSummaryUseCase } from '@core/racing/application/use-cases/SendPerformanceSummaryUseCase';
|
||||
import { SendFinalResultsUseCase } from '@core/racing/application/use-cases/SendFinalResultsUseCase';
|
||||
import { CloseRaceEventStewardingUseCase } from '@core/racing/application/use-cases/CloseRaceEventStewardingUseCase';
|
||||
import { InMemoryRaceEventRepository } from '@core/racing/infrastructure/repositories/InMemoryRaceEventRepository';
|
||||
import { InMemorySessionRepository } from '@core/racing/infrastructure/repositories/InMemorySessionRepository';
|
||||
|
||||
// Mock notification service
|
||||
const mockNotificationService = {
|
||||
sendNotification: vi.fn(),
|
||||
};
|
||||
|
||||
// Test data builders
|
||||
const createTestSession = (overrides: Partial<{
|
||||
id: string;
|
||||
raceEventId: string;
|
||||
sessionType: SessionType;
|
||||
status: 'scheduled' | 'running' | 'completed';
|
||||
scheduledAt: Date;
|
||||
}> = {}) => {
|
||||
return Session.create({
|
||||
id: overrides.id ?? 'session-1',
|
||||
raceEventId: overrides.raceEventId ?? 'race-event-1',
|
||||
scheduledAt: overrides.scheduledAt ?? new Date(),
|
||||
track: 'Monza',
|
||||
car: 'F1 Car',
|
||||
sessionType: overrides.sessionType ?? SessionType.main(),
|
||||
status: overrides.status ?? 'scheduled',
|
||||
});
|
||||
};
|
||||
|
||||
const createTestRaceEvent = (overrides: Partial<{
|
||||
id: string;
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
name: string;
|
||||
sessions: Session[];
|
||||
status: 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed';
|
||||
stewardingClosesAt: Date;
|
||||
}> = {}) => {
|
||||
const sessions = overrides.sessions ?? [
|
||||
createTestSession({ id: 'practice-1', sessionType: SessionType.practice() }),
|
||||
createTestSession({ id: 'qualifying-1', sessionType: SessionType.qualifying() }),
|
||||
createTestSession({ id: 'main-1', sessionType: SessionType.main() }),
|
||||
];
|
||||
|
||||
return RaceEvent.create({
|
||||
id: overrides.id ?? 'race-event-1',
|
||||
seasonId: overrides.seasonId ?? 'season-1',
|
||||
leagueId: overrides.leagueId ?? 'league-1',
|
||||
name: overrides.name ?? 'Monza Grand Prix',
|
||||
sessions,
|
||||
status: overrides.status ?? 'scheduled',
|
||||
stewardingClosesAt: overrides.stewardingClosesAt,
|
||||
});
|
||||
};
|
||||
|
||||
describe('Race Event Performance Summary Notifications', () => {
|
||||
let raceEventRepository: InMemoryRaceEventRepository;
|
||||
let sessionRepository: InMemorySessionRepository;
|
||||
let sendPerformanceSummaryUseCase: SendPerformanceSummaryUseCase;
|
||||
let sendFinalResultsUseCase: SendFinalResultsUseCase;
|
||||
let closeStewardingUseCase: CloseRaceEventStewardingUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
raceEventRepository = new InMemoryRaceEventRepository();
|
||||
sessionRepository = new InMemorySessionRepository();
|
||||
sendPerformanceSummaryUseCase = new SendPerformanceSummaryUseCase(
|
||||
mockNotificationService as any,
|
||||
raceEventRepository as any,
|
||||
{} as any // Mock result repository
|
||||
);
|
||||
sendFinalResultsUseCase = new SendFinalResultsUseCase(
|
||||
mockNotificationService as any,
|
||||
raceEventRepository as any,
|
||||
{} as any // Mock result repository
|
||||
);
|
||||
closeStewardingUseCase = new CloseRaceEventStewardingUseCase(
|
||||
raceEventRepository as any,
|
||||
{} as any // Mock domain event publisher
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Performance Summary After Main Race Completion', () => {
|
||||
it('should send performance summary notification when main race completes', async () => {
|
||||
// Given
|
||||
const raceEvent = createTestRaceEvent();
|
||||
await raceEventRepository.create(raceEvent);
|
||||
|
||||
const mainRaceCompletedEvent = new MainRaceCompletedEvent({
|
||||
raceEventId: raceEvent.id,
|
||||
sessionId: 'main-1',
|
||||
leagueId: raceEvent.leagueId,
|
||||
seasonId: raceEvent.seasonId,
|
||||
completedAt: new Date(),
|
||||
driverIds: ['driver-1', 'driver-2'],
|
||||
});
|
||||
|
||||
// When
|
||||
await sendPerformanceSummaryUseCase.execute(mainRaceCompletedEvent);
|
||||
|
||||
// Then
|
||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
|
||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_performance_summary',
|
||||
urgency: 'modal',
|
||||
title: expect.stringContaining('Race Complete'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate provisional rating changes correctly', async () => {
|
||||
// Given
|
||||
const raceEvent = createTestRaceEvent();
|
||||
await raceEventRepository.create(raceEvent);
|
||||
|
||||
// Mock result repository to return position data
|
||||
const mockResultRepository = {
|
||||
findByRaceId: vi.fn().mockResolvedValue([
|
||||
{ driverId: 'driver-1', position: 1, incidents: 0, getPositionChange: () => 0 },
|
||||
]),
|
||||
};
|
||||
|
||||
const useCase = new SendPerformanceSummaryUseCase(
|
||||
mockNotificationService as any,
|
||||
raceEventRepository as any,
|
||||
mockResultRepository as any
|
||||
);
|
||||
|
||||
const event = new MainRaceCompletedEvent({
|
||||
raceEventId: raceEvent.id,
|
||||
sessionId: 'main-1',
|
||||
leagueId: raceEvent.leagueId,
|
||||
seasonId: raceEvent.seasonId,
|
||||
completedAt: new Date(),
|
||||
driverIds: ['driver-1'],
|
||||
});
|
||||
|
||||
// When
|
||||
await useCase.execute(event);
|
||||
|
||||
// Then
|
||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
provisionalRatingChange: 25, // P1 with 0 incidents = +25
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Final Results After Stewarding Closes', () => {
|
||||
it('should send final results notification when stewarding closes', async () => {
|
||||
// Given
|
||||
const raceEvent = createTestRaceEvent({ status: 'awaiting_stewarding' });
|
||||
await raceEventRepository.create(raceEvent);
|
||||
|
||||
const stewardingClosedEvent = new RaceEventStewardingClosedEvent({
|
||||
raceEventId: raceEvent.id,
|
||||
leagueId: raceEvent.leagueId,
|
||||
seasonId: raceEvent.seasonId,
|
||||
closedAt: new Date(),
|
||||
driverIds: ['driver-1', 'driver-2'],
|
||||
hadPenaltiesApplied: false,
|
||||
});
|
||||
|
||||
// When
|
||||
await sendFinalResultsUseCase.execute(stewardingClosedEvent);
|
||||
|
||||
// Then
|
||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
|
||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_final_results',
|
||||
urgency: 'modal',
|
||||
title: expect.stringContaining('Final Results'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stewarding Window Management', () => {
|
||||
it('should close expired stewarding windows', async () => {
|
||||
// Given
|
||||
const pastDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
|
||||
const raceEvent = createTestRaceEvent({
|
||||
status: 'awaiting_stewarding',
|
||||
stewardingClosesAt: pastDate,
|
||||
});
|
||||
await raceEventRepository.create(raceEvent);
|
||||
|
||||
// When
|
||||
await closeStewardingUseCase.execute({});
|
||||
|
||||
// Then
|
||||
const updatedEvent = await raceEventRepository.findById(raceEvent.id);
|
||||
expect(updatedEvent?.status).toBe('closed');
|
||||
});
|
||||
|
||||
it('should not close unexpired stewarding windows', async () => {
|
||||
// Given
|
||||
const futureDate = new Date(Date.now() + 25 * 60 * 60 * 1000); // 25 hours from now
|
||||
const raceEvent = createTestRaceEvent({
|
||||
status: 'awaiting_stewarding',
|
||||
stewardingClosesAt: futureDate,
|
||||
});
|
||||
await raceEventRepository.create(raceEvent);
|
||||
|
||||
// When
|
||||
await closeStewardingUseCase.execute({});
|
||||
|
||||
// Then
|
||||
const updatedEvent = await raceEventRepository.findById(raceEvent.id);
|
||||
expect(updatedEvent?.status).toBe('awaiting_stewarding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Race Event Lifecycle', () => {
|
||||
it('should transition from scheduled to in_progress when sessions start', () => {
|
||||
// Given
|
||||
const raceEvent = createTestRaceEvent({ status: 'scheduled' });
|
||||
|
||||
// When
|
||||
const startedEvent = raceEvent.start();
|
||||
|
||||
// Then
|
||||
expect(startedEvent.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('should transition to awaiting_stewarding when main race completes', () => {
|
||||
// Given
|
||||
const raceEvent = createTestRaceEvent({ status: 'in_progress' });
|
||||
|
||||
// When
|
||||
const completedEvent = raceEvent.completeMainRace();
|
||||
|
||||
// Then
|
||||
expect(completedEvent.status).toBe('awaiting_stewarding');
|
||||
});
|
||||
|
||||
it('should transition to closed when stewarding closes', () => {
|
||||
// Given
|
||||
const raceEvent = createTestRaceEvent({ status: 'awaiting_stewarding' });
|
||||
|
||||
// When
|
||||
const closedEvent = raceEvent.closeStewarding();
|
||||
|
||||
// Then
|
||||
expect(closedEvent.status).toBe('closed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Type Behavior', () => {
|
||||
it('should identify main race sessions correctly', () => {
|
||||
// Given
|
||||
const mainSession = createTestSession({ sessionType: SessionType.main() });
|
||||
const practiceSession = createTestSession({ sessionType: SessionType.practice() });
|
||||
|
||||
// Then
|
||||
expect(mainSession.countsForPoints()).toBe(true);
|
||||
expect(practiceSession.countsForPoints()).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify qualifying sessions correctly', () => {
|
||||
// Given
|
||||
const qualiSession = createTestSession({ sessionType: SessionType.qualifying() });
|
||||
const mainSession = createTestSession({ sessionType: SessionType.main() });
|
||||
|
||||
// Then
|
||||
expect(qualiSession.determinesGrid()).toBe(true);
|
||||
expect(mainSession.determinesGrid()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import {
|
||||
FixtureServer,
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
describe('Real Playwright hosted-session smoke (fixtures, steps 2–7)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 8000,
|
||||
mode: 'real',
|
||||
baseUrl,
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
expect(adapter.getPage()).not.toBeNull();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
if (server) {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectContextOpen(stepLabel: string) {
|
||||
const page = adapter.getPage();
|
||||
expect(page, `${stepLabel}: page should exist`).not.toBeNull();
|
||||
const closed = await page!.isClosed();
|
||||
expect(closed, `${stepLabel}: page should be open`).toBe(false);
|
||||
expect(adapter.isConnected(), `${stepLabel}: adapter stays connected`).toBe(true);
|
||||
}
|
||||
|
||||
async function navigateToFixtureStep(
|
||||
stepNumber: number,
|
||||
label: string,
|
||||
stepKey?: keyof typeof IRACING_SELECTORS.wizard.stepContainers,
|
||||
) {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(stepNumber));
|
||||
await page!.waitForLoadState('domcontentloaded');
|
||||
await expectContextOpen(`after navigate step ${stepNumber} (${label})`);
|
||||
|
||||
if (stepKey) {
|
||||
const selector = IRACING_SELECTORS.wizard.stepContainers[stepKey];
|
||||
const container = page!.locator(selector).first();
|
||||
const count = await container.count();
|
||||
expect(
|
||||
count,
|
||||
`${label}: expected container ${selector} to exist on fixture HTML`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
it(
|
||||
'keeps browser context open and reaches Time Limits using real adapter against fixtures',
|
||||
async () => {
|
||||
await navigateToFixtureStep(2, 'Create a Race');
|
||||
|
||||
const step2Result = await adapter.executeStep(
|
||||
StepId.create(2),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
expect(step2Result.success).toBe(true);
|
||||
await expectContextOpen('after step 2');
|
||||
|
||||
await navigateToFixtureStep(3, 'Race Information', 'raceInformation');
|
||||
|
||||
const step3Result = await adapter.executeStep(
|
||||
StepId.create(3),
|
||||
{
|
||||
sessionName: 'GridPilot Smoke Session',
|
||||
password: 'smokepw',
|
||||
description: 'Real Playwright smoke path using fixtures',
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step3Result.success).toBe(true);
|
||||
await expectContextOpen('after step 3');
|
||||
|
||||
await navigateToFixtureStep(4, 'Server Details', 'serverDetails');
|
||||
|
||||
const step4Result = await adapter.executeStep(
|
||||
StepId.create(4),
|
||||
{
|
||||
region: 'US',
|
||||
startNow: true,
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step4Result.success).toBe(true);
|
||||
await expectContextOpen('after step 4');
|
||||
|
||||
await navigateToFixtureStep(5, 'Set Admins', 'admins');
|
||||
|
||||
const step5Result = await adapter.executeStep(
|
||||
StepId.create(5),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
expect(step5Result.success).toBe(true);
|
||||
await expectContextOpen('after step 5');
|
||||
|
||||
await navigateToFixtureStep(6, 'Admins drawer', 'admins');
|
||||
|
||||
const step6Result = await adapter.executeStep(
|
||||
StepId.create(6),
|
||||
{
|
||||
adminSearch: 'Marc',
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step6Result.success).toBe(true);
|
||||
await expectContextOpen('after step 6');
|
||||
|
||||
await navigateToFixtureStep(7, 'Time Limits', 'timeLimit');
|
||||
|
||||
const step7Result = await adapter.executeStep(
|
||||
StepId.create(7),
|
||||
{
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
} as Record<string, unknown>,
|
||||
);
|
||||
expect(step7Result.success).toBe(true);
|
||||
await expectContextOpen('after step 7');
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText || '').toMatch(/Cars/i);
|
||||
|
||||
const overlay = await page!.$('#gridpilot-overlay');
|
||||
expect(overlay, 'overlay should be present in real mode').not.toBeNull();
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { DIContainer } from '../../../apps/companion/main/di-container';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
|
||||
import { PlaywrightAutomationAdapter } from 'core/automation/infrastructure//automation';
|
||||
|
||||
describe('Companion UI - hosted workflow via fixture-backed real stack', () => {
|
||||
let container: DIContainer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let sessionId: string;
|
||||
let originalEnv: string | undefined;
|
||||
let originalFixtureFlag: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
originalEnv = process.env.NODE_ENV;
|
||||
originalFixtureFlag = process.env.COMPANION_FIXTURE_HOSTED;
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'test',
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
});
|
||||
process.env.COMPANION_FIXTURE_HOSTED = '1';
|
||||
|
||||
DIContainer.resetInstance();
|
||||
container = DIContainer.getInstance();
|
||||
|
||||
const connection = await container.initializeBrowserConnection();
|
||||
expect(connection.success).toBe(true);
|
||||
|
||||
const browserAutomation = container.getBrowserAutomation();
|
||||
expect(browserAutomation).toBeInstanceOf(PlaywrightAutomationAdapter);
|
||||
adapter = browserAutomation as PlaywrightAutomationAdapter;
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
expect(adapter.getPage()).not.toBeNull();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await container.shutdown();
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
});
|
||||
process.env.COMPANION_FIXTURE_HOSTED = originalFixtureFlag;
|
||||
});
|
||||
|
||||
async function waitForFinalSession(deadlineMs: number) {
|
||||
const repo = container.getSessionRepository();
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
let finalSession = null;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repo.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && (finalSession.state.isStoppedAtStep18() || finalSession.state.isCompleted())) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for hosted workflow to complete via companion DI stack');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
|
||||
it(
|
||||
'drives AutomationEngineAdapter via DI over fixtures and shows overlay progress',
|
||||
async () => {
|
||||
const startUseCase = container.getStartAutomationUseCase();
|
||||
const repo = container.getSessionRepository();
|
||||
|
||||
const config: HostedSessionConfig = {
|
||||
sessionName: 'Companion E2E - fixture hosted workflow',
|
||||
serverName: 'Companion Fixture Server',
|
||||
password: 'companion',
|
||||
adminPassword: 'admin-companion',
|
||||
maxDrivers: 20,
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
weatherType: 'dynamic',
|
||||
timeOfDay: 'afternoon',
|
||||
sessionDuration: 60,
|
||||
practiceLength: 10,
|
||||
qualifyingLength: 10,
|
||||
warmupLength: 5,
|
||||
raceLength: 30,
|
||||
startType: 'standing',
|
||||
restarts: 'single-file',
|
||||
damageModel: 'realistic',
|
||||
trackState: 'auto'
|
||||
};
|
||||
|
||||
const dto = await startUseCase.execute(config);
|
||||
expect(dto.state).toBe('PENDING');
|
||||
expect(dto.currentStep).toBe(1);
|
||||
sessionId = dto.sessionId;
|
||||
|
||||
const session = await repo.findById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.state.isPending()).toBe(true);
|
||||
|
||||
await adapter.navigateToPage('http://localhost:3456/');
|
||||
const engine = container.getAutomationEngine();
|
||||
await engine.executeStep(StepId.create(1), config);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
await page!.waitForSelector('#gridpilot-overlay', { state: 'attached', timeout: 30000 });
|
||||
const startingText = await page!.textContent('#gridpilot-action');
|
||||
expect(startingText ?? '').not.toEqual('');
|
||||
|
||||
let reachedStep7OrBeyond = false;
|
||||
|
||||
const deadlineForProgress = Date.now() + 60000;
|
||||
while (Date.now() < deadlineForProgress) {
|
||||
const updated = await repo.findById(sessionId);
|
||||
if (updated && updated.currentStep.value >= 7) {
|
||||
reachedStep7OrBeyond = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
expect(reachedStep7OrBeyond).toBe(true);
|
||||
|
||||
const overlayStepText = await page!.textContent('#gridpilot-step-text');
|
||||
const overlayBody = (overlayStepText ?? '').trim().toLowerCase();
|
||||
expect(overlayBody.length).toBeGreaterThan(0);
|
||||
|
||||
const finalSession = await waitForFinalSession(60000);
|
||||
expect(finalSession.state.isStoppedAtStep18() || finalSession.state.isCompleted()).toBe(true);
|
||||
expect(finalSession.errorMessage).toBeUndefined();
|
||||
|
||||
const progressState = finalSession.state.value;
|
||||
expect(['STOPPED_AT_STEP_18', 'COMPLETED']).toContain(progressState);
|
||||
},
|
||||
180000
|
||||
);
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
Feature: Hosted Session Automation
|
||||
As a league organizer using the GridPilot companion app
|
||||
I want to automate the iRacing hosted session creation workflow
|
||||
So that I can quickly set up race sessions without manual data entry
|
||||
|
||||
Background:
|
||||
Given the companion app is running
|
||||
And I am authenticated with iRacing
|
||||
And I have a valid session configuration
|
||||
|
||||
Scenario: Complete 18-step automation workflow
|
||||
Given I have a session configuration with:
|
||||
| field | value |
|
||||
| sessionName | League Race Week 1 |
|
||||
| trackId | spa |
|
||||
| carIds | dallara-f3 |
|
||||
When I start the automation session
|
||||
Then the session should be created with state "PENDING"
|
||||
And the current step should be 1
|
||||
|
||||
When the automation progresses through all 18 steps
|
||||
Then step 1 should navigate to "Hosted Racing"
|
||||
And step 2 should click "Create a Race"
|
||||
And step 3 should fill "Race Information"
|
||||
And step 4 should configure "Server Details"
|
||||
And step 5 should access "Set Admins"
|
||||
And step 6 should handle "Add an Admin" modal
|
||||
And step 7 should set "Time Limits"
|
||||
And step 8 should access "Set Cars"
|
||||
And step 9 should handle "Add a Car" modal
|
||||
And step 10 should configure "Set Car Classes"
|
||||
And step 11 should access "Set Track"
|
||||
And step 12 should handle "Add a Track" modal
|
||||
And step 13 should configure "Track Options"
|
||||
And step 14 should set "Time of Day"
|
||||
And step 15 should configure "Weather"
|
||||
And step 16 should set "Race Options"
|
||||
And step 17 should configure "Team Driving"
|
||||
And step 18 should reach "Track Conditions"
|
||||
|
||||
And the session should stop at step 18
|
||||
And the session state should be "STOPPED_AT_STEP_18"
|
||||
And a manual submit warning should be displayed
|
||||
|
||||
Scenario: Modal step handling (step 6 - Add Admin)
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 6
|
||||
When the "Add an Admin" modal appears
|
||||
Then the automation should detect the modal
|
||||
And the automation should wait for modal content to load
|
||||
And the automation should fill admin fields
|
||||
And the automation should close the modal
|
||||
And the automation should transition to step 7
|
||||
|
||||
Scenario: Modal step handling (step 9 - Add Car)
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 9
|
||||
When the "Add a Car" modal appears
|
||||
Then the automation should detect the modal
|
||||
And the automation should select the car "dallara-f3"
|
||||
And the automation should confirm the selection
|
||||
And the automation should close the modal
|
||||
And the automation should transition to step 10
|
||||
|
||||
Scenario: Modal step handling (step 12 - Add Track)
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 12
|
||||
When the "Add a Track" modal appears
|
||||
Then the automation should detect the modal
|
||||
And the automation should select the track "spa"
|
||||
And the automation should confirm the selection
|
||||
And the automation should close the modal
|
||||
And the automation should transition to step 13
|
||||
|
||||
Scenario: Safety checkpoint at step 18
|
||||
Given I have started an automation session
|
||||
And the automation has progressed to step 17
|
||||
When the automation transitions to step 18
|
||||
Then the automation should automatically stop
|
||||
And the session state should be "STOPPED_AT_STEP_18"
|
||||
And the current step should be 18
|
||||
And no submit action should be executed
|
||||
And a notification should inform the user to review before submitting
|
||||
|
||||
Scenario: Pause and resume automation
|
||||
Given I have started an automation session
|
||||
And the automation is at step 5
|
||||
When I pause the automation
|
||||
Then the session state should be "PAUSED"
|
||||
And the current step should remain 5
|
||||
|
||||
When I resume the automation
|
||||
Then the session state should be "IN_PROGRESS"
|
||||
And the automation should continue from step 5
|
||||
|
||||
Scenario: Automation failure handling
|
||||
Given I have started an automation session
|
||||
And the automation is at step 8
|
||||
When a browser automation error occurs
|
||||
Then the session should transition to "FAILED" state
|
||||
And an error message should be recorded
|
||||
And the session should have a completedAt timestamp
|
||||
And the user should be notified of the failure
|
||||
|
||||
Scenario: Invalid configuration rejection
|
||||
Given I have a session configuration with:
|
||||
| field | value |
|
||||
| sessionName | |
|
||||
| trackId | spa |
|
||||
| carIds | dallara-f3|
|
||||
When I attempt to start the automation session
|
||||
Then the session creation should fail
|
||||
And an error message should indicate "Session name cannot be empty"
|
||||
And no session should be persisted
|
||||
|
||||
Scenario: Sequential step progression enforcement
|
||||
Given I have started an automation session
|
||||
And the automation is at step 5
|
||||
When I attempt to skip directly to step 7
|
||||
Then the transition should be rejected
|
||||
And an error message should indicate "Cannot skip steps"
|
||||
And the current step should remain 5
|
||||
|
||||
Scenario: Backward step prevention
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 10
|
||||
When I attempt to move back to step 9
|
||||
Then the transition should be rejected
|
||||
And an error message should indicate "Cannot move backward"
|
||||
And the current step should remain 10
|
||||
|
||||
Scenario: Multiple car selection
|
||||
Given I have a session configuration with:
|
||||
| field | value |
|
||||
| sessionName | Multi-class Race |
|
||||
| trackId | spa |
|
||||
| carIds | dallara-f3,porsche-911-gt3,bmw-m4-gt4 |
|
||||
When I start the automation session
|
||||
And the automation reaches step 9
|
||||
Then all three cars should be added via the modal
|
||||
And the automation should handle the modal three times
|
||||
And the automation should transition to step 10
|
||||
|
||||
Scenario: Session state persistence
|
||||
Given I have started an automation session
|
||||
And the automation has reached step 12
|
||||
When the application restarts
|
||||
Then the session should be recoverable from storage
|
||||
And the session state should be "IN_PROGRESS"
|
||||
And the current step should be 12
|
||||
And the session configuration should be intact
|
||||
|
||||
Scenario: Concurrent session prevention
|
||||
Given I have started an automation session
|
||||
And the session is in progress
|
||||
When I attempt to start another automation session
|
||||
Then the second session creation should be queued or rejected
|
||||
And a warning should inform about the active session
|
||||
|
||||
Scenario: Elapsed time tracking
|
||||
Given I have started an automation session
|
||||
When the automation runs for 5 seconds
|
||||
And I query the session status
|
||||
Then the elapsed time should be approximately 5000 milliseconds
|
||||
And the elapsed time should increase while in progress
|
||||
|
||||
Scenario: Complete workflow with realistic timings
|
||||
Given I have a session configuration
|
||||
When I start the automation session
|
||||
Then each step should take between 200ms and 1000ms
|
||||
And modal steps should take longer than regular steps
|
||||
And the total workflow should complete in under 30 seconds
|
||||
And the session should stop at step 18 without submitting
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import {
|
||||
IRACING_SELECTORS,
|
||||
IRACING_TIMEOUTS,
|
||||
} from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
const shouldRun = process.env.HOSTED_REAL_E2E === '1';
|
||||
const describeMaybe = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeMaybe('Real-site hosted session – Cars flow (members.iracing.com)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
mode: 'real',
|
||||
baseUrl: '',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const step2Result = await adapter.executeStep(StepId.create(2), {});
|
||||
expect(step2Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
await expect(
|
||||
createRaceButton.count(),
|
||||
'Create Race button should exist on Hosted Racing page',
|
||||
).resolves.toBeGreaterThan(0);
|
||||
|
||||
await createRaceButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const raceInfoContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.raceInformation)
|
||||
.first();
|
||||
await raceInfoContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await raceInfoContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const sessionConfig = {
|
||||
sessionName: 'GridPilot Real – Cars flow',
|
||||
password: 'cars-flow-secret',
|
||||
description: 'Real-site cars flow short path',
|
||||
};
|
||||
const step3Result = await adapter.executeStep(StepId.create(3), sessionConfig);
|
||||
expect(step3Result.success).toBe(true);
|
||||
|
||||
const carsSidebarLink = page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.cars)
|
||||
.first();
|
||||
await carsSidebarLink.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await carsSidebarLink.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
await carsContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'opens Add Car UI on real site and lists at least one car',
|
||||
async () => {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
await carsContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
await addCarButton.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||
|
||||
await addCarButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const addCarModal = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarModal)
|
||||
.first();
|
||||
await addCarModal.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await addCarModal.count()).toBeGreaterThan(0);
|
||||
|
||||
const carsTable = addCarModal
|
||||
.locator('table.table.table-striped tbody tr')
|
||||
.first();
|
||||
await carsTable.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
const rowCount = await addCarModal
|
||||
.locator('table.table.table-striped tbody tr')
|
||||
.count();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import {
|
||||
IRACING_SELECTORS,
|
||||
IRACING_TIMEOUTS,
|
||||
} from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
const shouldRun = process.env.HOSTED_REAL_E2E === '1';
|
||||
|
||||
const describeMaybe = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeMaybe('Real-site hosted session smoke – login and wizard entry (members.iracing.com)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
mode: 'real',
|
||||
baseUrl: '',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'logs in, reaches Hosted Racing, and opens Create Race wizard',
|
||||
async () => {
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
await expect(
|
||||
createRaceButton.count(),
|
||||
'Create Race button should exist on Hosted Racing page',
|
||||
).resolves.toBeGreaterThan(0);
|
||||
|
||||
const hostedTab = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.hostedTab)
|
||||
.first();
|
||||
await hostedTab.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const step2Result = await adapter.executeStep(StepId.create(2), {});
|
||||
expect(step2Result.success).toBe(true);
|
||||
|
||||
const modalSelector = IRACING_SELECTORS.hostedRacing.createRaceModal;
|
||||
const modal = page!.locator(modalSelector).first();
|
||||
await modal.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const newRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.newRaceButton)
|
||||
.first();
|
||||
await newRaceButton.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
await newRaceButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const raceInfoContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.raceInformation)
|
||||
.first();
|
||||
await raceInfoContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const modalContent = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.modalContent)
|
||||
.first()
|
||||
.count();
|
||||
expect(
|
||||
modalContent,
|
||||
'Race creation wizard modal content should be present',
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
|
||||
it(
|
||||
'detects login guard and does not attempt Create a Race when not authenticated',
|
||||
async () => {
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const currentUrl = page!.url();
|
||||
expect(currentUrl).not.toEqual('about:blank');
|
||||
expect(currentUrl.toLowerCase()).toContain('iracing');
|
||||
expect(currentUrl.toLowerCase()).toSatisfy((u: string) =>
|
||||
u.includes('oauth.iracing.com') ||
|
||||
u.includes('members.iracing.com') ||
|
||||
u.includes('/login'),
|
||||
);
|
||||
|
||||
const emailInput = page!
|
||||
.locator(IRACING_SELECTORS.login.emailInput)
|
||||
.first();
|
||||
const passwordInput = page!
|
||||
.locator(IRACING_SELECTORS.login.passwordInput)
|
||||
.first();
|
||||
|
||||
const hasEmail = (await emailInput.count()) > 0;
|
||||
const hasPassword = (await passwordInput.count()) > 0;
|
||||
|
||||
if (!hasEmail && !hasPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
await emailInput.waitFor({
|
||||
state: 'visible',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await passwordInput.waitFor({
|
||||
state: 'visible',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
const createRaceCount = await createRaceButton.count();
|
||||
|
||||
expect(createRaceCount).toBe(0);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import {
|
||||
IRACING_SELECTORS,
|
||||
IRACING_TIMEOUTS,
|
||||
} from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
const shouldRun = process.env.HOSTED_REAL_E2E === '1';
|
||||
const describeMaybe = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeMaybe('Real-site hosted session – Race Information step (members.iracing.com)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
mode: 'real',
|
||||
baseUrl: '',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
|
||||
const step1Result = await adapter.executeStep(StepId.create(1), {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
const step2Result = await adapter.executeStep(StepId.create(2), {});
|
||||
expect(step2Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const createRaceButton = page!
|
||||
.locator(IRACING_SELECTORS.hostedRacing.createRaceButton)
|
||||
.first();
|
||||
await expect(
|
||||
createRaceButton.count(),
|
||||
'Create Race button should exist on Hosted Racing page',
|
||||
).resolves.toBeGreaterThan(0);
|
||||
|
||||
await createRaceButton.click({ timeout: IRACING_TIMEOUTS.elementWait });
|
||||
|
||||
const raceInfoContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.raceInformation)
|
||||
.first();
|
||||
await raceInfoContainer.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
expect(await raceInfoContainer.count()).toBeGreaterThan(0);
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'shows Race Information sidebar text matching fixtures and keeps text inputs writable',
|
||||
async () => {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarLink = page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.raceInformation)
|
||||
.first();
|
||||
await sidebarLink.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
const sidebarText = (await sidebarLink.innerText()).trim();
|
||||
expect(sidebarText.length).toBeGreaterThan(0);
|
||||
|
||||
let fixtureSidebarText: string | null = null;
|
||||
try {
|
||||
const fixturePath = path.join(
|
||||
process.cwd(),
|
||||
'html-dumps-optimized',
|
||||
'iracing-hosted-sessions',
|
||||
'03-race-information.json',
|
||||
);
|
||||
const raw = await fs.readFile(fixturePath, 'utf8');
|
||||
const items = JSON.parse(raw) as Array<{ i: string; t: string }>;
|
||||
const sidebarItem =
|
||||
items.find(
|
||||
(i) =>
|
||||
i.i === 'wizard-sidebar-link-set-session-information' &&
|
||||
typeof i.t === 'string',
|
||||
) ?? null;
|
||||
if (sidebarItem) {
|
||||
fixtureSidebarText = sidebarItem.t;
|
||||
}
|
||||
} catch {
|
||||
fixtureSidebarText = null;
|
||||
}
|
||||
|
||||
if (fixtureSidebarText) {
|
||||
const expected = fixtureSidebarText.toLowerCase();
|
||||
const actual = sidebarText.toLowerCase();
|
||||
expect(
|
||||
actual.includes('race') || actual.includes(expected.slice(0, 4)),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const config = {
|
||||
sessionName: 'GridPilot Real – Race Information',
|
||||
password: 'real-site-secret',
|
||||
description: 'Real-site Race Information writable fields check',
|
||||
};
|
||||
|
||||
const result = await adapter.executeStep(StepId.create(3), config);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const sessionNameInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.sessionName)
|
||||
.first();
|
||||
const passwordInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.password)
|
||||
.first();
|
||||
const descriptionInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.description)
|
||||
.first();
|
||||
|
||||
await sessionNameInput.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await passwordInput.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
await descriptionInput.waitFor({
|
||||
state: 'attached',
|
||||
timeout: IRACING_TIMEOUTS.elementWait,
|
||||
});
|
||||
|
||||
const sessionNameValue = await sessionNameInput.inputValue();
|
||||
const passwordValue = await passwordInput.inputValue();
|
||||
const descriptionValue = await descriptionInput.inputValue();
|
||||
|
||||
expect(sessionNameValue).toBe(config.sessionName);
|
||||
expect(passwordValue).toBe(config.password);
|
||||
expect(descriptionValue).toBe(config.description);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Legacy Cucumber step definitions for real iRacing automation.
|
||||
*
|
||||
* Native OS-level automation and these steps have been retired.
|
||||
* This file is excluded from TypeScript builds and is kept only as
|
||||
* historical documentation. No executable step definitions remain.
|
||||
*/
|
||||
export {};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 1 – hosted racing', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Hosted Racing page in mock wizard', async () => {
|
||||
await harness.navigateToFixtureStep(1);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Create a Race');
|
||||
|
||||
const result = await harness.executeStep(1, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 2 – create race', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('opens the real Create Race confirmation modal with Last Settings / New Race options', async () => {
|
||||
await harness.navigateToFixtureStep(2);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const bodyTextBefore = await page!.textContent('body');
|
||||
expect(bodyTextBefore).toContain('Create a Race');
|
||||
|
||||
const result = await harness.executeStep(2, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
await page!.waitForSelector(
|
||||
IRACING_SELECTORS.hostedRacing.createRaceModal,
|
||||
);
|
||||
|
||||
const modalText = await page!.textContent(
|
||||
IRACING_SELECTORS.hostedRacing.createRaceModal,
|
||||
);
|
||||
expect(modalText).toMatch(/Last Settings/i);
|
||||
expect(modalText).toMatch(/New Race/i);
|
||||
|
||||
const lastSettingsButton = await page!.$(
|
||||
IRACING_SELECTORS.hostedRacing.lastSettingsButton,
|
||||
);
|
||||
const newRaceButton = await page!.$(
|
||||
IRACING_SELECTORS.hostedRacing.newRaceButton,
|
||||
);
|
||||
|
||||
expect(lastSettingsButton).not.toBeNull();
|
||||
expect(newRaceButton).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 3 – race information', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('fills race information on Race Information page and persists values in form fields', async () => {
|
||||
await harness.navigateToFixtureStep(3);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarRaceInfo = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.raceInformation)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarRaceInfo).toMatch(/Race Information/i);
|
||||
|
||||
const config = {
|
||||
sessionName: 'GridPilot E2E Session',
|
||||
password: 'secret',
|
||||
description: 'Step 3 race information E2E',
|
||||
};
|
||||
|
||||
const result = await harness.executeStep(3, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const sessionNameInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.sessionName)
|
||||
.first();
|
||||
const passwordInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.password)
|
||||
.first();
|
||||
const descriptionInput = page!
|
||||
.locator(IRACING_SELECTORS.steps.description)
|
||||
.first();
|
||||
|
||||
const sessionNameValue = await sessionNameInput.inputValue();
|
||||
const passwordValue = await passwordInput.inputValue();
|
||||
const descriptionValue = await descriptionInput.inputValue();
|
||||
|
||||
expect(sessionNameValue).toBe(config.sessionName);
|
||||
expect(passwordValue).toBe(config.password);
|
||||
expect(descriptionValue).toBe(config.description);
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Server Details|Admins/i);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 4 – server details', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Server Details page, applies region/start toggle, and progresses toward Admins', async () => {
|
||||
await harness.navigateToFixtureStep(4);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarServerDetails = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.serverDetails)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarServerDetails).toMatch(/Server Details/i);
|
||||
|
||||
const serverDetailsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.serverDetails)
|
||||
.first();
|
||||
expect(await serverDetailsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const config = {
|
||||
region: 'US-East-OH',
|
||||
startNow: true,
|
||||
};
|
||||
|
||||
const result = await harness.executeStep(4, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const currentServerHeader = await page!
|
||||
.locator('#set-server-details button:has-text("Current Server")')
|
||||
.first()
|
||||
.innerText();
|
||||
expect(currentServerHeader.toLowerCase()).toContain('us-east');
|
||||
|
||||
const startToggle = page!
|
||||
.locator(IRACING_SELECTORS.steps.startNow)
|
||||
.first();
|
||||
const startNowChecked =
|
||||
(await startToggle.getAttribute('checked')) !== null ||
|
||||
(await startToggle.getAttribute('aria-checked')) === 'true';
|
||||
expect(startNowChecked).toBe(true);
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Admins/i);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 5 – set admins', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Set Admins page and leaves at least one admin in the selected admins table when progressing to Time Limit', async () => {
|
||||
await harness.navigateToFixtureStep(5);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarAdmins = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.admins)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarAdmins).toMatch(/Admins/i);
|
||||
|
||||
const adminsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.admins)
|
||||
.first();
|
||||
expect(await adminsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Add an Admin');
|
||||
|
||||
const result = await harness.executeStep(5, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const selectedAdminsText =
|
||||
(await page!.textContent(
|
||||
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||
)) ?? '';
|
||||
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toContain('Time Limit');
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 6 – admins', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('completes successfully from Set Admins page and leaves selected admins populated', async () => {
|
||||
await harness.navigateToFixtureStep(5);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarAdmins = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.admins)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarAdmins).toMatch(/Admins/i);
|
||||
|
||||
const adminsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.admins)
|
||||
.first();
|
||||
expect(await adminsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const result = await harness.executeStep(6, {
|
||||
adminSearch: 'Marc',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const selectedAdminsText =
|
||||
(await page!.textContent(
|
||||
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||
)) ?? '';
|
||||
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toContain('Time Limit');
|
||||
});
|
||||
|
||||
it('handles Add Admin drawer state without regression and preserves selected admins list', async () => {
|
||||
await harness.navigateToFixtureStep(6);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const adminsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.admins)
|
||||
.first();
|
||||
expect(await adminsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const header = await page!.textContent('#set-admins .card-header');
|
||||
expect(header).toContain('Set Admins');
|
||||
|
||||
const result = await harness.executeStep(6, {
|
||||
adminSearch: 'Mintel',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const selectedAdminsText =
|
||||
(await page!.textContent(
|
||||
'#set-admins tbody[data-testid="admin-display-name-list"]',
|
||||
)) ?? '';
|
||||
expect(selectedAdminsText.trim()).not.toEqual('');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toContain('Time Limit');
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 7 – time limits', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Time Limits page, applies sliders, and navigates to Cars', async () => {
|
||||
await harness.navigateToFixtureStep(7);
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const timeLimitContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.timeLimit)
|
||||
.first();
|
||||
expect(await timeLimitContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const result = await harness.executeStep(7, {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const raceSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.race)
|
||||
.first();
|
||||
const raceSliderExists = await raceSlider.count();
|
||||
expect(raceSliderExists).toBeGreaterThan(0);
|
||||
const raceValueAttr =
|
||||
(await raceSlider.getAttribute('data-value')) ??
|
||||
(await raceSlider.inputValue().catch(() => null));
|
||||
expect(raceValueAttr).toBe('20');
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Cars/i);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 8 – cars', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
describe('alignment', () => {
|
||||
it('executes on Cars page in mock wizard and exposes Add Car UI', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
const addCarText = await addCarButton.innerText();
|
||||
expect(addCarText.toLowerCase()).toContain('add a car');
|
||||
|
||||
const result = await harness.executeStepWithFixtureMismatch(8, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state validation', () => {
|
||||
it('fails validation when executed on Track page instead of Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStepWithFixtureMismatch(8, {});
|
||||
}).rejects.toThrow(/Step 8 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('fails fast on Step 8 if already past Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStepWithFixtureMismatch(8, {});
|
||||
}).rejects.toThrow(/Step 8 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('passes validation when on Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const result = await harness.executeStepWithFixtureMismatch(8, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 9 – add car', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('adds a real car using the JSON-backed car list on Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const result = await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state validation', () => {
|
||||
it('throws when executed on Track page instead of Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Mazda MX-5',
|
||||
});
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('detects state mismatch when Cars button is missing', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Porsche 911',
|
||||
});
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('detects when Track container is present instead of Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Ferrari 488',
|
||||
});
|
||||
}).rejects.toThrow(/Step 9 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('passes validation when on Cars page', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const result = await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('provides detailed error context in validation failure', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'BMW M4',
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(errorMessage).toContain('Step 9');
|
||||
expect(errorMessage).toMatch(/validation|mismatch|wrong page/i);
|
||||
});
|
||||
|
||||
it('validates page state before attempting any Step 9 actions', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Page not available');
|
||||
}
|
||||
|
||||
let carModalOpened = false;
|
||||
page.on('framenavigated', () => {
|
||||
carModalOpened = true;
|
||||
});
|
||||
|
||||
let validationError = false;
|
||||
try {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'Audi R8',
|
||||
});
|
||||
} catch {
|
||||
validationError = true;
|
||||
}
|
||||
|
||||
expect(validationError).toBe(true);
|
||||
expect(carModalOpened).toBe(false);
|
||||
});
|
||||
|
||||
it('checks wizard footer state in Step 9', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStepWithFixtureMismatch(9, {
|
||||
carSearch: 'McLaren 720S',
|
||||
});
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 10 – car classes', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Car Classes page and keeps wizard on Track path', async () => {
|
||||
await harness.navigateToFixtureStep(10);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Add a Car Class');
|
||||
|
||||
const result = await harness.executeStep(10, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Track/i);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 11 – track', () => {
|
||||
describe('state validation', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('fails validation when executed on Cars page instead of Track page', async () => {
|
||||
await harness.navigateToFixtureStep(8);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(async () => {
|
||||
await harness.executeStep(11, {});
|
||||
}).rejects.toThrow(/Step 11 FAILED validation/i);
|
||||
});
|
||||
|
||||
it('passes validation when on Track page', async () => {
|
||||
await harness.navigateToFixtureStep(11);
|
||||
await harness.adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
|
||||
const result = await harness.executeStep(11, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 12 – add track', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Add Track modal from Track step', async () => {
|
||||
await harness.navigateToFixtureStep(12);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarTrack = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-track',
|
||||
);
|
||||
expect(sidebarTrack).toContain('Track');
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/Add a Track/i);
|
||||
|
||||
const result = await harness.executeStep(12, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Track Options/i);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 13 – track options', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Track Options page in mock wizard', async () => {
|
||||
await harness.navigateToFixtureStep(13);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarTrackOptions = await page!
|
||||
.locator(IRACING_SELECTORS.wizard.sidebarLinks.trackOptions)
|
||||
.first()
|
||||
.innerText();
|
||||
expect(sidebarTrackOptions).toMatch(/Track Options/i);
|
||||
|
||||
const trackOptionsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.trackOptions)
|
||||
.first();
|
||||
expect(await trackOptionsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toContain('Create Starting Grid');
|
||||
|
||||
const result = await harness.executeStep(13, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 14 – time of day', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Time of Day page and applies time-of-day slider from config', async () => {
|
||||
await harness.navigateToFixtureStep(14);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const container = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.timeOfDay)
|
||||
.first();
|
||||
expect(await container.count()).toBeGreaterThan(0);
|
||||
|
||||
const sidebarTimeOfDay = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-time-of-day',
|
||||
);
|
||||
expect(sidebarTimeOfDay).toContain('Time of Day');
|
||||
|
||||
const config = { timeOfDay: 800 };
|
||||
|
||||
const result = await harness.executeStep(14, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const timeSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.timeOfDay)
|
||||
.first();
|
||||
const sliderExists = await timeSlider.count();
|
||||
expect(sliderExists).toBeGreaterThan(0);
|
||||
|
||||
const valueAttr =
|
||||
(await timeSlider.getAttribute('data-value')) ??
|
||||
(await timeSlider.inputValue().catch(() => null));
|
||||
expect(valueAttr).toBe(String(config.timeOfDay));
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Weather/i);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
|
||||
describe('Step 15 – weather', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Weather page in mock wizard and applies weather config from JSON-backed controls', async () => {
|
||||
await harness.navigateToFixtureStep(15);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarWeather = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-weather',
|
||||
);
|
||||
expect(sidebarWeather).toContain('Weather');
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/Weather Mode|Event weather/i);
|
||||
|
||||
const config = {
|
||||
weatherType: '2',
|
||||
temperature: 650,
|
||||
};
|
||||
|
||||
const result = await harness.executeStep(15, config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const weatherSelect = page!
|
||||
.locator(IRACING_SELECTORS.steps.weatherType)
|
||||
.first();
|
||||
const weatherSelectCount = await weatherSelect.count();
|
||||
|
||||
if (weatherSelectCount > 0) {
|
||||
const selectedWeatherValue =
|
||||
(await weatherSelect.getAttribute('value')) ??
|
||||
(await weatherSelect.textContent().catch(() => null));
|
||||
expect(
|
||||
(selectedWeatherValue ?? '').toLowerCase(),
|
||||
).toMatch(/static|forecast|timeline|2/);
|
||||
} else {
|
||||
const radioGroup = page!.locator('[role="radiogroup"] input[type="radio"]').first();
|
||||
const radioCount = await radioGroup.count();
|
||||
expect(radioCount).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const tempSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.temperature)
|
||||
.first();
|
||||
const tempExists = await tempSlider.count();
|
||||
|
||||
if (tempExists > 0) {
|
||||
const tempValue =
|
||||
(await tempSlider.getAttribute('data-value')) ??
|
||||
(await tempSlider.inputValue().catch(() => null));
|
||||
expect(tempValue).toBe(String(config.temperature));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 16 – race options', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Race Options page in mock wizard', async () => {
|
||||
await harness.navigateToFixtureStep(16);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarRaceOptions = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-race-options',
|
||||
);
|
||||
expect(sidebarRaceOptions).toContain('Race Options');
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/No Incident Penalty|Select Discipline/i);
|
||||
|
||||
const result = await harness.executeStep(16, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const footerText = await page!.textContent('.wizard-footer');
|
||||
expect(footerText).toMatch(/Track Conditions/i);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
import { CheckoutConfirmation } from 'apps/companion/main/automation/domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
describe('Step 17 – team driving', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('executes on Team Driving page and completes without checkout', async () => {
|
||||
await harness.navigateToFixtureStep(17);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/Team Driving|Track Conditions/i);
|
||||
|
||||
const result = await harness.executeStep(17, {
|
||||
trackState: 'medium',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requests checkout confirmation and uses the user decision', async () => {
|
||||
await harness.navigateToFixtureStep(17);
|
||||
|
||||
let called = false;
|
||||
|
||||
harness.adapter.setCheckoutConfirmationCallback(async (price, state) => {
|
||||
called = true;
|
||||
expect(price).toBeDefined();
|
||||
expect(state).toBeDefined();
|
||||
return CheckoutConfirmation.create('confirmed');
|
||||
});
|
||||
|
||||
const result = await harness.executeStep(17, {
|
||||
trackState: 'medium',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type { StepHarness } from '../support/StepHarness';
|
||||
import { createStepHarness } from '../support/StepHarness';
|
||||
|
||||
describe('Step 18 – track conditions (manual stop)', () => {
|
||||
let harness: StepHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createStepHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
it('treats Track Conditions as manual stop without invoking automation step 18', async () => {
|
||||
await harness.navigateToFixtureStep(18);
|
||||
|
||||
const page = harness.adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const sidebarTrackConditions = await page!.textContent(
|
||||
'#wizard-sidebar-link-set-track-conditions',
|
||||
);
|
||||
expect(sidebarTrackConditions).toContain('Track Conditions');
|
||||
|
||||
const trackConditionsContainer = page!.locator('#set-track-conditions').first();
|
||||
expect(await trackConditionsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const bodyText = await page!.textContent('body');
|
||||
expect(bodyText).toMatch(/Track Conditions|Starting Track State/i);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import type { PlaywrightAutomationAdapter } from 'core/automation/infrastructure//automation';
|
||||
import type { AutomationResult } from 'apps/companion/main/automation/application/ports/AutomationResults';
|
||||
|
||||
export function assertAutoNavigationConfig(config: Record<string, unknown>): void {
|
||||
const skipFixtureNavigationFlag =
|
||||
(config as { __skipFixtureNavigation?: unknown }).__skipFixtureNavigation;
|
||||
if (skipFixtureNavigationFlag === true) {
|
||||
throw new Error('__skipFixtureNavigation is forbidden in auto-navigation suites');
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeStepWithAutoNavigationGuard(
|
||||
adapter: PlaywrightAutomationAdapter,
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
assertAutoNavigationConfig(config);
|
||||
return adapter.executeStep(StepId.create(step), config);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Permission status for E2E tests requiring real automation.
|
||||
*/
|
||||
export interface E2EPermissionStatus {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
platform: NodeJS.Platform;
|
||||
isCI: boolean;
|
||||
isHeadless: boolean;
|
||||
canRunRealAutomation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of permission check with actionable information.
|
||||
*/
|
||||
export interface PermissionCheckResult {
|
||||
canProceed: boolean;
|
||||
shouldSkip: boolean;
|
||||
skipReason?: string;
|
||||
status: E2EPermissionStatus;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGuard for E2E tests.
|
||||
*
|
||||
* Checks macOS Accessibility and Screen Recording permissions
|
||||
* required for real native automation. Provides graceful skip
|
||||
* logic for CI environments or when permissions are unavailable.
|
||||
*/
|
||||
export class PermissionGuard {
|
||||
private cachedStatus: E2EPermissionStatus | null = null;
|
||||
|
||||
/**
|
||||
* Check all permissions and determine if real automation tests can run.
|
||||
*
|
||||
* @returns PermissionCheckResult with status and skip reason if applicable
|
||||
*/
|
||||
async checkPermissions(): Promise<PermissionCheckResult> {
|
||||
const status = await this.getPermissionStatus();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// CI environments should always skip real automation tests
|
||||
if (status.isCI) {
|
||||
return {
|
||||
canProceed: false,
|
||||
shouldSkip: true,
|
||||
skipReason: 'Running in CI environment - real automation tests require a display',
|
||||
status,
|
||||
warnings: ['CI environment detected, skipping real automation tests'],
|
||||
};
|
||||
}
|
||||
|
||||
// Headless environments cannot run real automation
|
||||
if (status.isHeadless) {
|
||||
return {
|
||||
canProceed: false,
|
||||
shouldSkip: true,
|
||||
skipReason: 'Running in headless environment - real automation tests require a display',
|
||||
status,
|
||||
warnings: ['Headless environment detected, skipping real automation tests'],
|
||||
};
|
||||
}
|
||||
|
||||
// macOS-specific permission checks
|
||||
if (status.platform === 'darwin') {
|
||||
if (!status.accessibility) {
|
||||
warnings.push('macOS Accessibility permission not granted');
|
||||
warnings.push('To grant: System Preferences > Security & Privacy > Privacy > Accessibility');
|
||||
}
|
||||
if (!status.screenRecording) {
|
||||
warnings.push('macOS Screen Recording permission not granted');
|
||||
warnings.push('To grant: System Preferences > Security & Privacy > Privacy > Screen Recording');
|
||||
}
|
||||
|
||||
if (!status.accessibility || !status.screenRecording) {
|
||||
return {
|
||||
canProceed: false,
|
||||
shouldSkip: true,
|
||||
skipReason: `Missing macOS permissions: ${[
|
||||
!status.accessibility && 'Accessibility',
|
||||
!status.screenRecording && 'Screen Recording',
|
||||
].filter(Boolean).join(', ')}`,
|
||||
status,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return {
|
||||
canProceed: true,
|
||||
shouldSkip: false,
|
||||
status,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current permission status.
|
||||
*/
|
||||
async getPermissionStatus(): Promise<E2EPermissionStatus> {
|
||||
if (this.cachedStatus) {
|
||||
return this.cachedStatus;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const isCI = this.detectCI();
|
||||
const isHeadless = await this.detectHeadless();
|
||||
|
||||
let accessibility = true;
|
||||
let screenRecording = true;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
accessibility = await this.checkMacOSAccessibility();
|
||||
screenRecording = await this.checkMacOSScreenRecording();
|
||||
}
|
||||
|
||||
const canRunRealAutomation =
|
||||
!isCI &&
|
||||
!isHeadless &&
|
||||
accessibility &&
|
||||
screenRecording;
|
||||
|
||||
this.cachedStatus = {
|
||||
accessibility,
|
||||
screenRecording,
|
||||
platform,
|
||||
isCI,
|
||||
isHeadless,
|
||||
canRunRealAutomation,
|
||||
};
|
||||
|
||||
return this.cachedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running in a CI environment.
|
||||
*/
|
||||
private detectCI(): boolean {
|
||||
return !!(
|
||||
process.env.CI ||
|
||||
process.env.CONTINUOUS_INTEGRATION ||
|
||||
process.env.GITHUB_ACTIONS ||
|
||||
process.env.GITLAB_CI ||
|
||||
process.env.CIRCLECI ||
|
||||
process.env.TRAVIS ||
|
||||
process.env.JENKINS_URL ||
|
||||
process.env.BUILDKITE ||
|
||||
process.env.TF_BUILD // Azure DevOps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running in a headless environment (no display).
|
||||
*/
|
||||
private async detectHeadless(): Promise<boolean> {
|
||||
// Check for explicit headless environment variable
|
||||
if (process.env.HEADLESS === 'true' || process.env.DISPLAY === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// On Linux, check if DISPLAY is set
|
||||
if (process.platform === 'linux' && !process.env.DISPLAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// On macOS, check if we're in a non-GUI session
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
// Check if we can access the WindowServer
|
||||
const { stdout } = await execAsync('pgrep -x WindowServer');
|
||||
return !stdout.trim();
|
||||
} catch {
|
||||
// pgrep returns non-zero if no process found
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check macOS Accessibility permission without Electron.
|
||||
* Uses AppleScript to test if we can control system events.
|
||||
*/
|
||||
private async checkMacOSAccessibility(): Promise<boolean> {
|
||||
try {
|
||||
// Try to use AppleScript to check accessibility
|
||||
// This will fail if accessibility permission is not granted
|
||||
await execAsync(`osascript -e 'tell application "System Events" to return name of first process'`);
|
||||
return true;
|
||||
} catch {
|
||||
// Permission denied or System Events not accessible
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check macOS Screen Recording permission without Electron.
|
||||
* Uses `screencapture` heuristics to detect denial.
|
||||
*/
|
||||
private async checkMacOSScreenRecording(): Promise<boolean> {
|
||||
try {
|
||||
const { stderr } = await execAsync('screencapture -x -c 2>&1 || true');
|
||||
|
||||
if (stderr.includes('permission') || stderr.includes('denied')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached permission status.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedStatus = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format permission status for logging.
|
||||
*/
|
||||
formatStatus(status: E2EPermissionStatus): string {
|
||||
const lines = [
|
||||
`Platform: ${status.platform}`,
|
||||
`CI Environment: ${status.isCI ? 'Yes' : 'No'}`,
|
||||
`Headless: ${status.isHeadless ? 'Yes' : 'No'}`,
|
||||
`Accessibility Permission: ${status.accessibility ? '✓' : '✗'}`,
|
||||
`Screen Recording Permission: ${status.screenRecording ? '✓' : '✗'}`,
|
||||
`Can Run Real Automation: ${status.canRunRealAutomation ? '✓' : '✗'}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for use in tests.
|
||||
*/
|
||||
export const permissionGuard = new PermissionGuard();
|
||||
|
||||
/**
|
||||
* Skip helper for Cucumber tests.
|
||||
* Call in Before hook to skip tests if permissions are unavailable.
|
||||
*
|
||||
* @returns Skip reason if tests should be skipped, undefined otherwise
|
||||
*/
|
||||
export async function shouldSkipRealAutomationTests(): Promise<string | undefined> {
|
||||
const result = await permissionGuard.checkPermissions();
|
||||
|
||||
if (result.shouldSkip) {
|
||||
console.warn('\n⚠️ Skipping real automation tests:');
|
||||
console.warn(` ${result.skipReason}`);
|
||||
if (result.warnings.length > 0) {
|
||||
result.warnings.forEach(w => console.warn(` - ${w}`));
|
||||
}
|
||||
return result.skipReason;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that real automation can proceed.
|
||||
* Throws an error with detailed information if not.
|
||||
*/
|
||||
export async function assertCanRunRealAutomation(): Promise<void> {
|
||||
const result = await permissionGuard.checkPermissions();
|
||||
|
||||
if (!result.canProceed) {
|
||||
const status = permissionGuard.formatStatus(result.status);
|
||||
throw new Error(
|
||||
`Cannot run real automation tests:\n${result.skipReason}\n\nPermission Status:\n${status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { AutomationResult } from 'apps/companion/main/automation/application/ports/AutomationResults';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
export interface StepHarness {
|
||||
server: FixtureServer;
|
||||
adapter: PlaywrightAutomationAdapter;
|
||||
baseUrl: string;
|
||||
getFixtureUrl(step: number): string;
|
||||
navigateToFixtureStep(step: number): Promise<void>;
|
||||
executeStep(step: number, config: Record<string, unknown>): Promise<AutomationResult>;
|
||||
executeStepWithAutoNavigation(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult>;
|
||||
executeStepWithFixtureMismatch(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
async function createRealAdapter(baseUrl: string): Promise<PlaywrightAutomationAdapter> {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
const adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 8000,
|
||||
mode: 'real',
|
||||
baseUrl,
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to connect Playwright adapter');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
async function createMockAdapter(): Promise<PlaywrightAutomationAdapter> {
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
const adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 5000,
|
||||
mode: 'mock',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to connect mock Playwright adapter');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export async function createStepHarness(useMock: boolean = false): Promise<StepHarness> {
|
||||
const server = new FixtureServer();
|
||||
const { url } = await server.start();
|
||||
|
||||
const adapter = useMock ? await createMockAdapter() : await createRealAdapter(url);
|
||||
|
||||
async function navigateToFixtureStep(step: number): Promise<void> {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(step));
|
||||
await adapter.getPage()?.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
async function executeStepWithAutoNavigation(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
const skipFixtureNavigationFlag =
|
||||
(config as { __skipFixtureNavigation?: unknown }).__skipFixtureNavigation;
|
||||
if (skipFixtureNavigationFlag === true) {
|
||||
throw new Error(
|
||||
'__skipFixtureNavigation is not allowed in auto-navigation path',
|
||||
);
|
||||
}
|
||||
return adapter.executeStep(StepId.create(step), config);
|
||||
}
|
||||
|
||||
async function executeStepWithFixtureMismatch(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
return adapter.executeStep(StepId.create(step), {
|
||||
...config,
|
||||
__skipFixtureNavigation: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeStep(
|
||||
step: number,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AutomationResult> {
|
||||
return executeStepWithFixtureMismatch(step, config);
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
await adapter.disconnect();
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
return {
|
||||
server,
|
||||
adapter,
|
||||
baseUrl: url,
|
||||
getFixtureUrl: (step) => server.getFixtureUrl(step),
|
||||
navigateToFixtureStep,
|
||||
executeStep,
|
||||
executeStepWithAutoNavigation,
|
||||
executeStepWithFixtureMismatch,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
import { executeStepWithAutoNavigationGuard } from '../support/AutoNavGuard';
|
||||
|
||||
describe('Hosted validator guards (fixture-backed, real stack)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 15_000,
|
||||
baseUrl,
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (adapter) {
|
||||
await adapter.disconnect();
|
||||
}
|
||||
if (server) {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'runs a short hosted sequence (3 → 4 → 5) with autonav and no validator failures',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(3));
|
||||
const step3Result = await executeStepWithAutoNavigationGuard(adapter, 3, {
|
||||
sessionName: 'Validator happy-path session',
|
||||
password: 'validator',
|
||||
description: 'Validator autonav slice',
|
||||
});
|
||||
expect(step3Result.success).toBe(true);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(4));
|
||||
const step4Result = await executeStepWithAutoNavigationGuard(adapter, 4, {
|
||||
region: 'US',
|
||||
startNow: true,
|
||||
});
|
||||
expect(step4Result.success).toBe(true);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(5));
|
||||
const step5Result = await executeStepWithAutoNavigationGuard(adapter, 5, {});
|
||||
expect(step5Result.success).toBe(true);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
it(
|
||||
'fails clearly when executing a mismatched step on the wrong page (validator wiring)',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||
const stepId = StepId.create(11);
|
||||
|
||||
await expect(
|
||||
adapter.executeStep(stepId, {
|
||||
trackSearch: 'Spa',
|
||||
__skipFixtureNavigation: true,
|
||||
}),
|
||||
).rejects.toThrow(/Step 11 FAILED validation|validation error/i);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
import { executeStepWithAutoNavigationGuard } from '../support/AutoNavGuard';
|
||||
|
||||
describe('Workflow – hosted session autonav slice (fixture-backed, real stack)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 15_000,
|
||||
baseUrl,
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await adapter.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
async function expectStepOnContainer(
|
||||
expectedContainer: keyof typeof IRACING_SELECTORS.wizard.stepContainers,
|
||||
) {
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
const selector = IRACING_SELECTORS.wizard.stepContainers[expectedContainer];
|
||||
const container = page!.locator(selector).first();
|
||||
await container.waitFor({ state: 'attached', timeout: 10_000 });
|
||||
expect(await container.count()).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
it(
|
||||
'navigates via autonav across representative steps (1 → 3 → 7 → 9 → 13 → 17)',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(1));
|
||||
const step1Result = await executeStepWithAutoNavigationGuard(adapter, 1, {});
|
||||
expect(step1Result.success).toBe(true);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(3));
|
||||
const step3Result = await executeStepWithAutoNavigationGuard(adapter, 3, {
|
||||
sessionName: 'Autonav workflow session',
|
||||
password: 'autonav',
|
||||
description: 'Fixture-backed autonav slice',
|
||||
});
|
||||
expect(step3Result.success).toBe(true);
|
||||
await expectStepOnContainer('raceInformation');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||
const step7Result = await executeStepWithAutoNavigationGuard(adapter, 7, {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
expect(step7Result.success).toBe(true);
|
||||
await expectStepOnContainer('timeLimit');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||
const step9Result = await executeStepWithAutoNavigationGuard(adapter, 9, {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
expect(step9Result.success).toBe(true);
|
||||
await expectStepOnContainer('cars');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(13));
|
||||
const step13Result = await executeStepWithAutoNavigationGuard(adapter, 13, {
|
||||
trackSearch: 'Spa',
|
||||
});
|
||||
expect(step13Result.success).toBe(true);
|
||||
await expectStepOnContainer('trackOptions');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(17));
|
||||
const step17Result = await executeStepWithAutoNavigationGuard(adapter, 17, {
|
||||
trackState: 'medium',
|
||||
});
|
||||
expect(step17Result.success).toBe(true);
|
||||
await expectStepOnContainer('raceOptions');
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import { InMemorySessionRepository } from 'apps/companion/main/automation/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { AutomationEngineAdapter } from 'core/automation/infrastructure//automation/engine/AutomationEngineAdapter';
|
||||
import { StartAutomationSessionUseCase } from 'apps/companion/main/automation/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
describe('Workflow – hosted session end-to-end (fixture-backed, real stack)', () => {
|
||||
let server: FixtureServer;
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 10_000,
|
||||
baseUrl,
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const connectResult = await adapter.connect(false);
|
||||
expect(connectResult.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await adapter.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
function createRealEngine() {
|
||||
const repository = new InMemorySessionRepository();
|
||||
const engine = new AutomationEngineAdapter(adapter, repository);
|
||||
const useCase = new StartAutomationSessionUseCase(engine, adapter, repository);
|
||||
return { repository, engine, useCase };
|
||||
}
|
||||
|
||||
it(
|
||||
'runs 1–17 from use case and stops automation at manual Track Conditions (STOPPED_AT_STEP_18)',
|
||||
async () => {
|
||||
const { repository, engine, useCase } = createRealEngine();
|
||||
|
||||
const config: any = {
|
||||
sessionName: 'Fixture E2E – full workflow (real stack)',
|
||||
trackId: 'spa',
|
||||
carIds: ['dallara-f3'],
|
||||
};
|
||||
|
||||
const dto = await useCase.execute(config);
|
||||
|
||||
expect(dto.state).toBe('PENDING');
|
||||
expect(dto.currentStep).toBe(1);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(1));
|
||||
|
||||
await engine.executeStep(StepId.create(1), config);
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
let finalSession = null;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const sessions = await repository.findAll();
|
||||
finalSession = sessions[0] ?? null;
|
||||
|
||||
if (finalSession && finalSession.state.isStoppedAtStep18()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('Timed out waiting for automation workflow to complete');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
expect(finalSession).not.toBeNull();
|
||||
expect(finalSession!.state.isStoppedAtStep18()).toBe(true);
|
||||
expect(finalSession!.currentStep.value).toBe(17);
|
||||
expect(finalSession!.startedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.completedAt).toBeInstanceOf(Date);
|
||||
expect(finalSession!.errorMessage).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
PlaywrightAutomationAdapter,
|
||||
FixtureServer,
|
||||
} from 'core/automation/infrastructure//automation';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import { IRACING_SELECTORS } from 'core/automation/infrastructure//automation/dom/IRacingSelectors';
|
||||
import { PinoLogAdapter } from 'core/automation/infrastructure//logging/PinoLogAdapter';
|
||||
|
||||
describe('Workflow – steps 7–9 cars flow (fixture-backed, real stack)', () => {
|
||||
let adapter: PlaywrightAutomationAdapter;
|
||||
let server: FixtureServer;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new FixtureServer();
|
||||
const info = await server.start();
|
||||
baseUrl = info.url;
|
||||
|
||||
const logger = new PinoLogAdapter();
|
||||
|
||||
adapter = new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: true,
|
||||
timeout: 8000,
|
||||
baseUrl,
|
||||
mode: 'real',
|
||||
userDataDir: '',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const result = await adapter.connect(false);
|
||||
expect(result.success).toBe(true);
|
||||
expect(adapter.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await adapter.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
it(
|
||||
'executes time limits, cars, and add car in sequence using fixtures and leaves DOM-backed state',
|
||||
async () => {
|
||||
await adapter.navigateToPage(server.getFixtureUrl(7));
|
||||
const step7Result = await adapter.executeStep(StepId.create(7), {
|
||||
practice: 10,
|
||||
qualify: 10,
|
||||
race: 20,
|
||||
});
|
||||
expect(step7Result.success).toBe(true);
|
||||
|
||||
const page = adapter.getPage();
|
||||
expect(page).not.toBeNull();
|
||||
|
||||
const raceSlider = page!
|
||||
.locator(IRACING_SELECTORS.steps.race)
|
||||
.first();
|
||||
const raceSliderValue =
|
||||
(await raceSlider.getAttribute('data-value')) ??
|
||||
(await raceSlider.inputValue().catch(() => null));
|
||||
expect(raceSliderValue).toBe('20');
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(8));
|
||||
const step8Result = await adapter.executeStep(StepId.create(8), {});
|
||||
expect(step8Result.success).toBe(true);
|
||||
|
||||
const carsContainer = page!
|
||||
.locator(IRACING_SELECTORS.wizard.stepContainers.cars)
|
||||
.first();
|
||||
expect(await carsContainer.count()).toBeGreaterThan(0);
|
||||
|
||||
const addCarButton = page!
|
||||
.locator(IRACING_SELECTORS.steps.addCarButton)
|
||||
.first();
|
||||
expect(await addCarButton.count()).toBeGreaterThan(0);
|
||||
|
||||
await adapter.navigateToPage(server.getFixtureUrl(9));
|
||||
const step9Result = await adapter.executeStep(StepId.create(9), {
|
||||
carSearch: 'Acura ARX-06',
|
||||
});
|
||||
expect(step9Result.success).toBe(true);
|
||||
|
||||
const carsTable = page!
|
||||
.locator('#select-car-set-cars table.table.table-striped')
|
||||
.first();
|
||||
expect(await carsTable.count()).toBeGreaterThan(0);
|
||||
|
||||
const acuraCell = carsTable.locator('tbody tr td >> text=Acura ARX-06 GTP');
|
||||
expect(await acuraCell.count()).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { container } from "tsyringe";
|
||||
import { configureDIContainer, resetDIContainer } from "../../../apps/companion/main/di-config";
|
||||
import { DI_TOKENS } from "../../../apps/companion/main/di-tokens";
|
||||
import { OverlaySyncService } from "@gridpilot/automation/application/services/OverlaySyncService";
|
||||
import { LoggerPort } from "@gridpilot/automation/application/ports/LoggerPort";
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from "@gridpilot/automation/infrastructure//IAutomationLifecycleEmitter";
|
||||
import { AutomationEventPublisherPort, AutomationEvent } from "@gridpilot/automation/application/ports/AutomationEventPublisherPort";
|
||||
import { ConsoleLogAdapter } from "@gridpilot/automation/infrastructure//logging/ConsoleLogAdapter";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, SpyInstance } from 'vitest';
|
||||
|
||||
describe("OverlaySyncService Integration with ConsoleLogAdapter", () => {
|
||||
let consoleErrorSpy: SpyInstance<[message?: any, ...optionalParams: any[]], void>;
|
||||
let consoleWarnSpy: SpyInstance<[message?: any, ...optionalParams: any[]], void>;
|
||||
let originalNodeEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
resetDIContainer();
|
||||
configureDIContainer();
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
if (originalNodeEnv !== undefined) {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
resetDIContainer();
|
||||
});
|
||||
|
||||
it("should use ConsoleLogAdapter and log messages when OverlaySyncService encounters an error", async () => {
|
||||
const logger = container.resolve<LoggerPort>(DI_TOKENS.Logger);
|
||||
const overlaySyncService = container.resolve<OverlaySyncService>(DI_TOKENS.OverlaySyncPort);
|
||||
|
||||
expect(logger).toBeInstanceOf(ConsoleLogAdapter);
|
||||
|
||||
const mockLifecycleEmitter: IAutomationLifecycleEmitter = {
|
||||
onLifecycle: vi.fn((_cb: LifecycleCallback) => {
|
||||
throw new Error("Test lifecycle emitter error");
|
||||
}),
|
||||
offLifecycle: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPublisher: AutomationEventPublisherPort = {
|
||||
publish: vi.fn(),
|
||||
};
|
||||
|
||||
const serviceWithMockedEmitter = new OverlaySyncService({
|
||||
lifecycleEmitter: mockLifecycleEmitter,
|
||||
publisher: mockPublisher,
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
const action = { id: "test-action-1", label: "Test Action" };
|
||||
await serviceWithMockedEmitter.execute(action);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("OverlaySyncService: failed to subscribe to lifecycleEmitter"),
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ actionId: action.id }),
|
||||
);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use ConsoleLogAdapter and log warn messages when OverlaySyncService fails to publish", async () => {
|
||||
const logger = container.resolve<LoggerPort>(DI_TOKENS.Logger);
|
||||
expect(logger).toBeInstanceOf(ConsoleLogAdapter);
|
||||
|
||||
const mockLifecycleEmitter: IAutomationLifecycleEmitter = {
|
||||
onLifecycle: vi.fn(),
|
||||
offLifecycle: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPublisher: AutomationEventPublisherPort = {
|
||||
publish: vi.fn((_event: AutomationEvent) => {
|
||||
throw new Error("Test publish error");
|
||||
}),
|
||||
};
|
||||
|
||||
const serviceWithMockedPublisher = new OverlaySyncService({
|
||||
lifecycleEmitter: mockLifecycleEmitter,
|
||||
publisher: mockPublisher,
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
const action = { id: "test-action-2", label: "Test Action" };
|
||||
await serviceWithMockedPublisher.execute(action);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("OverlaySyncService: publisher.publish failed"),
|
||||
expect.objectContaining({
|
||||
actionId: action.id,
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { DIContainer } from '../../../..//apps/companion/main/di-container';
|
||||
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import { PlaywrightAutomationAdapter } from '../../../../apps/companion/main/automation/infrastructure/automation';
|
||||
|
||||
describe('companion start automation - browser mode refresh wiring', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let originalTestLauncher: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, NODE_ENV: 'development' };
|
||||
|
||||
originalTestLauncher = (PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: unknown;
|
||||
}).testLauncher;
|
||||
|
||||
const mockLauncher = {
|
||||
launch: async (_opts: unknown) => ({
|
||||
newContext: async () => ({
|
||||
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
|
||||
close: async () => {},
|
||||
}),
|
||||
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
|
||||
close: async () => {},
|
||||
}),
|
||||
launchPersistentContext: async (_userDataDir: string, _opts: unknown) => ({
|
||||
pages: () => [{ setDefaultTimeout: () => {}, close: async () => {} }],
|
||||
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
|
||||
close: async () => {},
|
||||
}),
|
||||
};
|
||||
|
||||
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: typeof mockLauncher;
|
||||
}).testLauncher = mockLauncher;
|
||||
|
||||
DIContainer.resetInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
await container.shutdown();
|
||||
DIContainer.resetInstance();
|
||||
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: unknown;
|
||||
}).testLauncher = originalTestLauncher;
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('uses refreshed browser automation for connection and step execution after mode change', async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
|
||||
const loader = container.getBrowserModeConfigLoader();
|
||||
expect(loader.getDevelopmentMode()).toBe('headed');
|
||||
|
||||
const preStart = container.getStartAutomationUseCase();
|
||||
const preEngine = container.getAutomationEngine();
|
||||
const preAutomation = container.getBrowserAutomation();
|
||||
|
||||
expect(preAutomation).toBe(preEngine.browserAutomation);
|
||||
|
||||
loader.setDevelopmentMode('headless');
|
||||
container.refreshBrowserAutomation();
|
||||
|
||||
const postStart = container.getStartAutomationUseCase();
|
||||
const postEngine = container.getAutomationEngine();
|
||||
const postAutomation = container.getBrowserAutomation();
|
||||
|
||||
expect(postAutomation).toBe(postEngine.browserAutomation);
|
||||
expect(postAutomation).not.toBe(preAutomation);
|
||||
expect(postStart).not.toBe(preStart);
|
||||
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
expect(connectionResult.success).toBe(true);
|
||||
|
||||
const config: HostedSessionConfig = {
|
||||
sessionName: 'Companion browser-mode refresh wiring',
|
||||
trackId: 'test-track',
|
||||
carIds: ['car-1'],
|
||||
};
|
||||
|
||||
const dto = await postStart.execute(config);
|
||||
|
||||
await postEngine.executeStep(StepId.create(1), config);
|
||||
|
||||
const sessionRepository = container.getSessionRepository();
|
||||
const session = await sessionRepository.findById(dto.sessionId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
|
||||
const state = session!.state.value as string;
|
||||
const errorMessage = session!.errorMessage as string | undefined;
|
||||
|
||||
if (errorMessage) {
|
||||
expect(errorMessage).not.toContain('Browser not connected');
|
||||
}
|
||||
|
||||
const automationFromConnection = container.getBrowserAutomation();
|
||||
const automationFromEngine = (container.getAutomationEngine() as { browserAutomation: unknown })
|
||||
.browserAutomation;
|
||||
|
||||
expect(automationFromConnection).toBe(automationFromEngine);
|
||||
expect(automationFromConnection).toBe(postAutomation);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { DIContainer } from '../../../..//apps/companion/main/di-container';
|
||||
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
import { PlaywrightAutomationAdapter } from '../../../../apps/companion/main/automation/infrastructure/automation';
|
||||
|
||||
describe('companion start automation - browser not connected at step 1', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let originalTestLauncher: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, NODE_ENV: 'production' };
|
||||
|
||||
originalTestLauncher = (PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: unknown;
|
||||
}).testLauncher;
|
||||
|
||||
const mockLauncher = {
|
||||
launch: async (_opts: unknown) => ({
|
||||
newContext: async () => ({
|
||||
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
|
||||
close: async () => {},
|
||||
}),
|
||||
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
|
||||
close: async () => {},
|
||||
}),
|
||||
launchPersistentContext: async (_userDataDir: string, _opts: unknown) => ({
|
||||
pages: () => [{ setDefaultTimeout: () => {}, close: async () => {} }],
|
||||
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
|
||||
close: async () => {},
|
||||
}),
|
||||
};
|
||||
|
||||
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: typeof mockLauncher;
|
||||
}).testLauncher = mockLauncher;
|
||||
|
||||
DIContainer.resetInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
await container.shutdown();
|
||||
DIContainer.resetInstance();
|
||||
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: unknown;
|
||||
}).testLauncher = originalTestLauncher;
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('marks the session as FAILED with Step 1 (LOGIN) browser-not-connected error', async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
const startAutomationUseCase = container.getStartAutomationUseCase();
|
||||
const sessionRepository = container.getSessionRepository();
|
||||
const automationEngine = container.getAutomationEngine();
|
||||
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
expect(connectionResult.success).toBe(true);
|
||||
|
||||
const browserAutomation = container.getBrowserAutomation();
|
||||
if (typeof (browserAutomation as { disconnect?: () => Promise<void> }).disconnect === 'function') {
|
||||
await (browserAutomation as { disconnect: () => Promise<void> }).disconnect();
|
||||
}
|
||||
|
||||
const config: HostedSessionConfig = {
|
||||
sessionName: 'Companion integration browser-not-connected',
|
||||
trackId: 'test-track',
|
||||
carIds: ['car-1'],
|
||||
};
|
||||
|
||||
const dto = await startAutomationUseCase.execute(config);
|
||||
|
||||
await automationEngine.executeStep(StepId.create(1), config);
|
||||
|
||||
const session = await waitForFailedSession(sessionRepository, dto.sessionId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.state!.value).toBe('FAILED');
|
||||
const error = session!.errorMessage as string | undefined;
|
||||
expect(error).toBeDefined();
|
||||
expect(error).toContain('Step 1 (Navigate to Hosted Racing page)');
|
||||
expect(error).toContain('Browser not connected');
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForFailedSession(
|
||||
sessionRepository: { findById: (id: string) => Promise<{ state?: { value?: string }; errorMessage?: unknown } | null> },
|
||||
sessionId: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<{ state?: { value?: string }; errorMessage?: unknown } | null> {
|
||||
const start = Date.now();
|
||||
let last: any = null;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
last = await sessionRepository.findById(sessionId);
|
||||
if (last && last.state && last.state.value === 'FAILED') {
|
||||
return last;
|
||||
}
|
||||
if (Date.now() - start >= timeoutMs) {
|
||||
return last;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { DIContainer } from '../../../..//apps/companion/main/di-container';
|
||||
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
|
||||
import { PlaywrightAutomationAdapter } from '../../../../apps/companion/main/automation/infrastructure/automation';
|
||||
|
||||
describe('companion start automation - browser connection failure before steps', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
let originalTestLauncher: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, NODE_ENV: 'production' };
|
||||
|
||||
originalTestLauncher = (PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: unknown;
|
||||
}).testLauncher;
|
||||
|
||||
const failingLauncher = {
|
||||
launch: async () => {
|
||||
throw new Error('Simulated browser launch failure');
|
||||
},
|
||||
launchPersistentContext: async () => {
|
||||
throw new Error('Simulated persistent context failure');
|
||||
},
|
||||
};
|
||||
|
||||
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: typeof failingLauncher;
|
||||
}).testLauncher = failingLauncher;
|
||||
|
||||
DIContainer.resetInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
await container.shutdown();
|
||||
DIContainer.resetInstance();
|
||||
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: unknown;
|
||||
}).testLauncher = originalTestLauncher;
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('fails browser connection and aborts before executing step 1', async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
const startAutomationUseCase = container.getStartAutomationUseCase();
|
||||
const sessionRepository = container.getSessionRepository();
|
||||
const automationEngine = container.getAutomationEngine();
|
||||
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
expect(connectionResult.success).toBe(false);
|
||||
expect(connectionResult.error).toBeDefined();
|
||||
|
||||
const executeStepSpy = vi.spyOn(
|
||||
automationEngine,
|
||||
'executeStep',
|
||||
);
|
||||
|
||||
const config: HostedSessionConfig = {
|
||||
sessionName: 'Companion integration connection failure',
|
||||
trackId: 'test-track',
|
||||
carIds: ['car-1'],
|
||||
};
|
||||
|
||||
let sessionId: string | null = null;
|
||||
|
||||
try {
|
||||
const dto = await startAutomationUseCase.execute(config);
|
||||
sessionId = dto.sessionId;
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toBeDefined();
|
||||
}
|
||||
|
||||
expect(executeStepSpy).not.toHaveBeenCalled();
|
||||
|
||||
if (sessionId) {
|
||||
const session = await sessionRepository.findById(sessionId);
|
||||
if (session) {
|
||||
const message = session.errorMessage as string | undefined;
|
||||
if (message) {
|
||||
expect(message).not.toContain('Step 1 (LOGIN) failed: Browser not connected');
|
||||
expect(message.toLowerCase()).toContain('browser');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('treats successful adapter connect without a page as connection failure', async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
const browserAutomation = container.getBrowserAutomation();
|
||||
|
||||
expect(browserAutomation).toBeInstanceOf(PlaywrightAutomationAdapter);
|
||||
|
||||
const AdapterWithPrototype = PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
prototype: {
|
||||
connect: () => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
};
|
||||
const originalConnect = AdapterWithPrototype.prototype.connect;
|
||||
|
||||
AdapterWithPrototype.prototype.connect = async function () {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
try {
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
expect(connectionResult.success).toBe(false);
|
||||
expect(connectionResult.error).toBeDefined();
|
||||
expect(String(connectionResult.error).toLowerCase()).toContain('browser');
|
||||
} finally {
|
||||
AdapterWithPrototype.prototype.connect = originalConnect;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { DIContainer } from '../../../..//apps/companion/main/di-container';
|
||||
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
|
||||
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
|
||||
|
||||
describe('companion start automation - happy path', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, NODE_ENV: 'test' };
|
||||
DIContainer.resetInstance();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
await container.shutdown();
|
||||
DIContainer.resetInstance();
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('creates a non-failed session and does not report browser-not-connected', async () => {
|
||||
const container = DIContainer.getInstance();
|
||||
const startAutomationUseCase = container.getStartAutomationUseCase();
|
||||
const sessionRepository = container.getSessionRepository();
|
||||
const automationEngine = container.getAutomationEngine();
|
||||
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
expect(connectionResult.success).toBe(true);
|
||||
|
||||
const config: HostedSessionConfig = {
|
||||
sessionName: 'Companion integration happy path',
|
||||
trackId: 'test-track',
|
||||
carIds: ['car-1'],
|
||||
};
|
||||
|
||||
const dto = await startAutomationUseCase.execute(config);
|
||||
|
||||
const sessionBefore = await sessionRepository.findById(dto.sessionId);
|
||||
expect(sessionBefore).toBeDefined();
|
||||
|
||||
await automationEngine.executeStep(StepId.create(1), config);
|
||||
|
||||
const session = await sessionRepository.findById(dto.sessionId);
|
||||
expect(session).toBeDefined();
|
||||
|
||||
const state = session!.state.value as string;
|
||||
expect(state).not.toBe('FAILED');
|
||||
|
||||
const errorMessage = session!.errorMessage as string | undefined;
|
||||
if (errorMessage) {
|
||||
expect(errorMessage).not.toContain('Browser not connected');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MockAutomationLifecycleEmitter } from '../../../mocks/MockAutomationLifecycleEmitter';
|
||||
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService';
|
||||
import type { AutomationEvent } from 'apps/companion/main/automation/application/ports/IAutomationEventPublisher';
|
||||
import type { OverlayAction } from 'apps/companion/main/automation/application/ports/IOverlaySyncPort';
|
||||
|
||||
type RendererOverlayState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'starting'; actionId: string }
|
||||
| { status: 'in-progress'; actionId: string }
|
||||
| { status: 'completed'; actionId: string }
|
||||
| { status: 'failed'; actionId: string };
|
||||
|
||||
class RecordingPublisher {
|
||||
public events: AutomationEvent[] = [];
|
||||
async publish(event: AutomationEvent): Promise<void> {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
function reduceEventsToRendererState(events: AutomationEvent[]): RendererOverlayState {
|
||||
let state: RendererOverlayState = { status: 'idle' };
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev.actionId) continue;
|
||||
switch (ev.type) {
|
||||
case 'modal-opened':
|
||||
case 'panel-attached':
|
||||
state = { status: 'starting', actionId: ev.actionId };
|
||||
break;
|
||||
case 'action-started':
|
||||
state = { status: 'in-progress', actionId: ev.actionId };
|
||||
break;
|
||||
case 'action-complete':
|
||||
state = { status: 'completed', actionId: ev.actionId };
|
||||
break;
|
||||
case 'action-failed':
|
||||
case 'panel-missing':
|
||||
state = { status: 'failed', actionId: ev.actionId };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('renderer overlay lifecycle integration', () => {
|
||||
it('tracks starting → in-progress → completed lifecycle for a hosted action', async () => {
|
||||
const emitter = new MockAutomationLifecycleEmitter();
|
||||
const publisher = new RecordingPublisher();
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter,
|
||||
publisher,
|
||||
logger: console as any,
|
||||
defaultTimeoutMs: 2_000,
|
||||
});
|
||||
|
||||
const action: OverlayAction = {
|
||||
id: 'hosted-session',
|
||||
label: 'Starting hosted session',
|
||||
};
|
||||
|
||||
const ackPromise = svc.startAction(action);
|
||||
|
||||
expect(publisher.events[0]?.type).toBe('modal-opened');
|
||||
expect(publisher.events[0]?.actionId).toBe('hosted-session');
|
||||
|
||||
await emitter.emit({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
});
|
||||
|
||||
await emitter.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');
|
||||
|
||||
await publisher.publish({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
} as AutomationEvent);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'action-started',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
} as AutomationEvent);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'action-complete',
|
||||
actionId: 'hosted-session',
|
||||
timestamp: Date.now(),
|
||||
} as AutomationEvent);
|
||||
|
||||
const rendererState = reduceEventsToRendererState(publisher.events);
|
||||
|
||||
expect(rendererState.status).toBe('completed');
|
||||
expect((rendererState as { actionId: string }).actionId).toBe('hosted-session');
|
||||
});
|
||||
|
||||
it('ends in failed state when panel-missing is emitted', async () => {
|
||||
const emitter = new MockAutomationLifecycleEmitter();
|
||||
const publisher = new RecordingPublisher();
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter,
|
||||
publisher,
|
||||
logger: console as any,
|
||||
defaultTimeoutMs: 200,
|
||||
});
|
||||
|
||||
const action: OverlayAction = {
|
||||
id: 'hosted-failure',
|
||||
label: 'Hosted session failing',
|
||||
};
|
||||
|
||||
void svc.startAction(action);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'panel-attached',
|
||||
actionId: 'hosted-failure',
|
||||
timestamp: Date.now(),
|
||||
payload: { selector: '#gridpilot-overlay' },
|
||||
} as AutomationEvent);
|
||||
|
||||
await publisher.publish({
|
||||
type: 'action-failed',
|
||||
actionId: 'hosted-failure',
|
||||
timestamp: Date.now(),
|
||||
payload: { reason: 'validation error' },
|
||||
} as AutomationEvent);
|
||||
|
||||
const rendererState = reduceEventsToRendererState(publisher.events);
|
||||
|
||||
expect(rendererState.status).toBe('failed');
|
||||
expect((rendererState as { actionId: string }).actionId).toBe('hosted-failure');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { MockAutomationLifecycleEmitter } from '../../../mocks/MockAutomationLifecycleEmitter'
|
||||
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService'
|
||||
|
||||
describe('renderer overlay integration', () => {
|
||||
test('renderer shows confirmed only after main acks confirmed', async () => {
|
||||
const emitter = new MockAutomationLifecycleEmitter()
|
||||
const publisher: { publish: (event: unknown) => Promise<void> } = { publish: async () => {} }
|
||||
const svc = new OverlaySyncService({
|
||||
lifecycleEmitter: emitter,
|
||||
publisher,
|
||||
logger: console as any,
|
||||
})
|
||||
|
||||
// simulate renderer request
|
||||
const promise = svc.startAction({ id: 'add-car', label: 'Adding...' })
|
||||
|
||||
// ack should be tentative until emitter emits action-started
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
const tentative = await Promise.race([promise, Promise.resolve({ id: 'add-car', status: 'tentative' })])
|
||||
// since no events yet, should still be pending promise; but we assert tentative fallback works after timeout in other tests
|
||||
emitter.emit({ type: 'action-started', actionId: 'add-car', timestamp: Date.now() })
|
||||
const ack = await promise
|
||||
expect(ack.status).toBe('confirmed')
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { DIContainer } from '@apps/companion/main/di-container';
|
||||
|
||||
test('renderer -> preload -> main: set/get updates BrowserModeConfigLoader (reproduces headless-toggle bug)', () => {
|
||||
// Ensure environment is development so toggle is available
|
||||
(process.env as any).NODE_ENV = 'development';
|
||||
|
||||
// Provide a minimal electron.app mock so DIContainer can resolve paths in node test environment
|
||||
// This avoids calling the real Electron runtime during unit/runner tests.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const electron = require('electron');
|
||||
electron.app = electron.app || {};
|
||||
electron.app.getAppPath = electron.app.getAppPath || (() => process.cwd());
|
||||
electron.app.isPackaged = electron.app.isPackaged || false;
|
||||
electron.app.getPath = electron.app.getPath || ((p: string) => process.cwd());
|
||||
} catch {
|
||||
// If require('electron') fails, ignore; DIContainer will still attempt to access app and may error.
|
||||
}
|
||||
|
||||
// Reset and get fresh DI container for test isolation
|
||||
DIContainer.resetInstance();
|
||||
const container = DIContainer.getInstance();
|
||||
const loader = container.getBrowserModeConfigLoader();
|
||||
|
||||
// Sanity: toggle visible and default is 'headed' in development
|
||||
expect(process.env.NODE_ENV).toBe('development');
|
||||
expect(loader.getDevelopmentMode()).toBe('headed');
|
||||
|
||||
// Simulate renderer setting to 'headless' via IPC (which should call loader.setDevelopmentMode)
|
||||
loader.setDevelopmentMode('headless');
|
||||
|
||||
// After setting, the loader must reflect new value
|
||||
expect(loader.getDevelopmentMode()).toBe('headless');
|
||||
|
||||
// loader.load() should report the GUI source in development and the updated mode
|
||||
const config = loader.load();
|
||||
expect(config.mode).toBe('headless');
|
||||
expect(config.source).toBe('GUI');
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Experimental Playwright+Electron companion boot smoke test (retired).
|
||||
*
|
||||
* This suite attempted to launch the Electron-based companion app via
|
||||
* Playwright's Electron driver, but it cannot run in this environment because
|
||||
* Electron embeds Node.js 16.17.1 while the installed Playwright version
|
||||
* requires Node.js 18 or higher.
|
||||
*
|
||||
* Companion behavior is instead covered by:
|
||||
* - Playwright-based automation E2Es and integrations against fixtures.
|
||||
* - Electron build/init/DI smoke tests.
|
||||
* - Domain and application unit/integration tests.
|
||||
*
|
||||
* This file now contains a minimal Vitest suite to keep the historical
|
||||
* entrypoint discoverable without failing the test runner.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('companion-boot smoke (retired)', () => {
|
||||
it('is documented as retired and covered elsewhere', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DIContainer, resolveTemplatePath, resolveSessionDataPath } from '@apps/companion/main/di-container';
|
||||
|
||||
describe('DIContainer (smoke) - test-tolerance', () => {
|
||||
it('constructs without electron.app and exposes path resolvers', () => {
|
||||
// Constructing DIContainer should not throw in plain Node (vitest) environment.
|
||||
expect(() => {
|
||||
// Note: getInstance lazily constructs the container
|
||||
DIContainer.resetInstance();
|
||||
DIContainer.getInstance();
|
||||
}).not.toThrow();
|
||||
|
||||
// Path resolvers should return strings
|
||||
const tpl = resolveTemplatePath();
|
||||
const sess = resolveSessionDataPath();
|
||||
expect(typeof tpl).toBe('string');
|
||||
expect(tpl.length).toBeGreaterThan(0);
|
||||
expect(typeof sess).toBe('string');
|
||||
expect(sess.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Legacy Electron app smoke suite (superseded).
|
||||
*
|
||||
* Canonical boot coverage now lives in
|
||||
* [companion-boot.smoke.test.ts](tests/smoke/companion-boot.smoke.test.ts).
|
||||
*
|
||||
* This file now contains a minimal Vitest suite to keep the historical
|
||||
* entrypoint discoverable without failing the test runner.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('electron-app smoke (superseded)', () => {
|
||||
it('is documented as superseded and covered elsewhere', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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.
|
||||
*
|
||||
* It now runs under the Playwright test runner used by the smoke suite.
|
||||
*/
|
||||
|
||||
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: string) => {
|
||||
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: string, index: number) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DIContainer } from '@apps/companion/main/di-container';
|
||||
import { StartAutomationSessionUseCase } from 'apps/companion/main/automation/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { CheckAuthenticationUseCase } from 'apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase';
|
||||
import { InitiateLoginUseCase } from 'apps/companion/main/automation/application/use-cases/InitiateLoginUseCase';
|
||||
import { ClearSessionUseCase } from 'apps/companion/main/automation/application/use-cases/ClearSessionUseCase';
|
||||
import { ConfirmCheckoutUseCase } from 'apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { PlaywrightAutomationAdapter } from 'core/automation/infrastructure//automation';
|
||||
import { InMemorySessionRepository } from 'apps/companion/main/automation/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { NoOpLogAdapter } from '@core/automation/infrastructure//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 as unknown as { instance?: unknown }).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();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Experimental Playwright+Electron companion boot harness (retired).
|
||||
*
|
||||
* This harness attempted to launch the Electron-based companion app via
|
||||
* Playwright's Electron driver, but it cannot run in this environment because
|
||||
* Electron embeds Node.js 16.17.1 while the installed Playwright version
|
||||
* requires Node.js 18 or higher.
|
||||
*
|
||||
* Companion behavior is instead covered by:
|
||||
* - Playwright-based automation E2Es and integrations against fixtures.
|
||||
* - Electron build/init/DI smoke tests.
|
||||
* - Domain and application unit/integration tests.
|
||||
*
|
||||
* This file is intentionally implementation-empty to avoid misleading
|
||||
* Playwright+Electron coverage while keeping the historical entrypoint
|
||||
* discoverable.
|
||||
*/
|
||||
@@ -1,134 +0,0 @@
|
||||
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) => {
|
||||
const errorObj: ConsoleError = {
|
||||
type: 'pageerror',
|
||||
message: error.message,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
if (error.stack) {
|
||||
errorObj.location = error.stack;
|
||||
}
|
||||
this.errors.push(errorObj);
|
||||
});
|
||||
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
const launchOptions: any = {
|
||||
args: [electronEntryPath],
|
||||
env: {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([_, v]) => v !== undefined)),
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
};
|
||||
if (process.env.ELECTRON_EXECUTABLE_PATH) {
|
||||
launchOptions.executablePath = process.env.ELECTRON_EXECUTABLE_PATH;
|
||||
}
|
||||
this.app = await electron.launch(launchOptions);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { ElectronApplication } from '@playwright/test';
|
||||
|
||||
type IpcHandlerResult = {
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
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 = 'auth:check';
|
||||
|
||||
try {
|
||||
const result = await this.app.evaluate(
|
||||
async ({ ipcMain }: { ipcMain: { listeners: (channel: string) => unknown[] } }) => {
|
||||
return new Promise((resolve) => {
|
||||
// Simulate IPC invoke handler by calling the first registered handler for the channel
|
||||
const handlers = ipcMain.listeners('auth:check') || [];
|
||||
const handler = handlers[0] as
|
||||
| ((event: unknown, ...args: unknown[]) => unknown | Promise<unknown>)
|
||||
| undefined;
|
||||
|
||||
if (!handler) {
|
||||
resolve({ error: 'Handler not registered' });
|
||||
} else {
|
||||
// Invoke the handler similar to ipcMain.handle invocation signature
|
||||
// (event, ...args) => Promise
|
||||
const mockEvent: unknown = {};
|
||||
Promise.resolve(handler(mockEvent))
|
||||
.then((res: unknown) => resolve(res))
|
||||
.catch((err: unknown) =>
|
||||
resolve({
|
||||
error:
|
||||
err && err instanceof Error && err.message
|
||||
? err.message
|
||||
: String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const typed: IpcHandlerResult = result as IpcHandlerResult;
|
||||
|
||||
const resultObj: IPCTestResult = {
|
||||
channel,
|
||||
success: !typed.error,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
if (typed.error) {
|
||||
resultObj.error = typed.error;
|
||||
}
|
||||
return resultObj;
|
||||
} 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 = 'browser-mode:get';
|
||||
|
||||
try {
|
||||
const result = await this.app.evaluate(
|
||||
async ({ ipcMain }: { ipcMain: { listeners: (channel: string) => unknown[] } }) => {
|
||||
return new Promise((resolve) => {
|
||||
const handlers = ipcMain.listeners('browser-mode:get') || [];
|
||||
const handler = handlers[0] as
|
||||
| ((event: unknown, ...args: unknown[]) => unknown | Promise<unknown>)
|
||||
| undefined;
|
||||
|
||||
if (!handler) {
|
||||
resolve({ error: 'Handler not registered' });
|
||||
} else {
|
||||
const mockEvent: unknown = {};
|
||||
Promise.resolve(handler(mockEvent))
|
||||
.then((res: unknown) => resolve(res))
|
||||
.catch((err: unknown) =>
|
||||
resolve({
|
||||
error:
|
||||
err && err instanceof Error && err.message
|
||||
? err.message
|
||||
: String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const typed: IpcHandlerResult = result as IpcHandlerResult;
|
||||
|
||||
const resultObj: IPCTestResult = {
|
||||
channel,
|
||||
success: !typed.error,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
if (typed.error) {
|
||||
resultObj.error = typed.error;
|
||||
}
|
||||
return resultObj;
|
||||
} 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 = 'start-automation';
|
||||
|
||||
try {
|
||||
const result = await this.app.evaluate(
|
||||
async ({ ipcMain }: { ipcMain: { listeners: (channel: string) => unknown[] } }) => {
|
||||
return new Promise((resolve) => {
|
||||
const handlers = ipcMain.listeners('start-automation') || [];
|
||||
const handler = handlers[0] as
|
||||
| ((
|
||||
event: unknown,
|
||||
payload: { sessionName: string; mode: string },
|
||||
) => unknown | Promise<unknown>)
|
||||
| undefined;
|
||||
|
||||
if (!handler) {
|
||||
resolve({ error: 'Handler not registered' });
|
||||
} else {
|
||||
// Test with mock data
|
||||
const mockEvent: unknown = {};
|
||||
Promise.resolve(
|
||||
handler(mockEvent, { sessionName: 'test', mode: 'test' }),
|
||||
)
|
||||
.then((res: unknown) => resolve(res))
|
||||
.catch((err: unknown) =>
|
||||
resolve({
|
||||
error:
|
||||
err && err instanceof Error && err.message
|
||||
? err.message
|
||||
: String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const typed: IpcHandlerResult = result as IpcHandlerResult;
|
||||
|
||||
const resultObj: IPCTestResult = {
|
||||
channel,
|
||||
success: !typed.error,
|
||||
duration: Date.now() - start,
|
||||
};
|
||||
if (typed.error) {
|
||||
resultObj.error = typed.error;
|
||||
}
|
||||
return resultObj;
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { PlaywrightAutomationAdapter, FixtureServer } from 'core/automation/infrastructure//automation';
|
||||
import { NoOpLogAdapter } from '@core/automation/infrastructure//logging/NoOpLogAdapter';
|
||||
|
||||
describe('Playwright Adapter Smoke Tests', () => {
|
||||
let adapter: PlaywrightAutomationAdapter | undefined;
|
||||
let server: FixtureServer | undefined;
|
||||
let unhandledRejectionHandler: ((reason: unknown) => void) | null = null;
|
||||
const logger = new NoOpLogAdapter();
|
||||
|
||||
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 as any).on('unhandledRejection', unhandledRejectionHandler);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (unhandledRejectionHandler) {
|
||||
(process as any).removeListener('unhandledRejection', unhandledRejectionHandler);
|
||||
unhandledRejectionHandler = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('Adapter instantiates without errors', () => {
|
||||
expect(() => {
|
||||
adapter = new PlaywrightAutomationAdapter({
|
||||
headless: true,
|
||||
mode: 'mock',
|
||||
timeout: 5000,
|
||||
}, logger);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('Browser connects successfully', async () => {
|
||||
adapter = new PlaywrightAutomationAdapter({
|
||||
headless: true,
|
||||
mode: 'mock',
|
||||
timeout: 5000,
|
||||
}, logger);
|
||||
|
||||
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,
|
||||
}, logger);
|
||||
|
||||
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,
|
||||
}, logger);
|
||||
const adapter2 = new PlaywrightAutomationAdapter({
|
||||
headless: true,
|
||||
mode: 'mock',
|
||||
timeout: 5000,
|
||||
}, logger);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user