remove companion tests

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

View File

@@ -1,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

View File

@@ -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);
});
});
});

View File

@@ -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 27)', () => {
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,
);
});

View File

@@ -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
);
});

View File

@@ -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

View File

@@ -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,
);
});

View File

@@ -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,
);
});

View File

@@ -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,
);
});

View File

@@ -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 {};

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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));
}
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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}`
);
}
}

View File

@@ -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,
};
}

View File

@@ -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,
);
});

View File

@@ -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,
);
});

View File

@@ -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 117 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();
},
);
});

View File

@@ -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 79 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);
},
);
});

View File

@@ -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();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -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));
}
}

View File

@@ -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;
}
});
});

View File

@@ -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');
}
});
});

View File

@@ -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');
});
});

View File

@@ -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')
})
})

View File

@@ -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');
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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.
*/

View File

@@ -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 = [];
}
}

View File

@@ -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;
}
}
}

View File

@@ -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');
}
}

View File

@@ -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();
});
});