rename to core
This commit is contained in:
282
core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts
Normal file
282
core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Query: GetEntityAnalyticsQuery
|
||||
*
|
||||
* Retrieves analytics data for an entity (league, driver, team, race).
|
||||
* Returns metrics formatted for display to sponsors and admins.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '@gridpilot/shared/application';
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import type { EntityType } from '../../domain/types/PageView';
|
||||
import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot';
|
||||
|
||||
export interface GetEntityAnalyticsInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
period?: SnapshotPeriod;
|
||||
since?: Date;
|
||||
}
|
||||
|
||||
export interface EntityAnalyticsOutput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
summary: {
|
||||
totalPageViews: number;
|
||||
uniqueVisitors: number;
|
||||
sponsorClicks: number;
|
||||
engagementScore: number;
|
||||
trustIndicator: 'high' | 'medium' | 'low';
|
||||
exposureValue: number;
|
||||
};
|
||||
trends: {
|
||||
pageViewsChange: number;
|
||||
uniqueVisitorsChange: number;
|
||||
engagementChange: number;
|
||||
};
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GetEntityAnalyticsQuery
|
||||
implements AsyncUseCase<GetEntityAnalyticsInput, EntityAnalyticsOutput> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly snapshotRepository: IAnalyticsSnapshotRepository,
|
||||
private readonly logger: ILogger
|
||||
) {}
|
||||
|
||||
async execute(input: GetEntityAnalyticsInput): Promise<EntityAnalyticsOutput> {
|
||||
this.logger.debug(`Executing GetEntityAnalyticsQuery with input: ${JSON.stringify(input)}`);
|
||||
const period = input.period ?? 'weekly';
|
||||
const now = new Date();
|
||||
const since = input.since ?? this.getPeriodStartDate(now, period);
|
||||
this.logger.debug(`Calculated period: ${period}, now: ${now.toISOString()}, since: ${since.toISOString()}`);
|
||||
|
||||
// Get current metrics
|
||||
let totalPageViews = 0;
|
||||
try {
|
||||
totalPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
this.logger.debug(`Total page views for entity ${input.entityId}: ${totalPageViews}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting total page views for entity ${input.entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let uniqueVisitors = 0;
|
||||
try {
|
||||
uniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
this.logger.debug(`Unique visitors for entity ${input.entityId}: ${uniqueVisitors}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting unique visitors for entity ${input.entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let sponsorClicks = 0;
|
||||
try {
|
||||
sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
this.logger.debug(`Sponsor clicks for entity ${input.entityId}: ${sponsorClicks}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting sponsor clicks for entity ${input.entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate engagement score (weighted sum of actions)
|
||||
let engagementScore = 0;
|
||||
try {
|
||||
engagementScore = await this.calculateEngagementScore(input.entityId, since);
|
||||
this.logger.debug(`Engagement score for entity ${input.entityId}: ${engagementScore}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error calculating engagement score for entity ${input.entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine trust indicator
|
||||
const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore);
|
||||
this.logger.debug(`Trust indicator for entity ${input.entityId}: ${trustIndicator}`);
|
||||
|
||||
// Calculate exposure value (for sponsor ROI)
|
||||
const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks);
|
||||
this.logger.debug(`Exposure value for entity ${input.entityId}: ${exposureValue}`);
|
||||
|
||||
// Get previous period for trends
|
||||
const previousPeriodStart = this.getPreviousPeriodStart(since, period);
|
||||
this.logger.debug(`Previous period start: ${previousPeriodStart.toISOString()}`);
|
||||
|
||||
let previousPageViews = 0;
|
||||
try {
|
||||
const fullPreviousPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
);
|
||||
previousPageViews = fullPreviousPageViews - totalPageViews; // This calculates change, not just previous period's total
|
||||
this.logger.debug(`Previous period full page views: ${fullPreviousPageViews}, change: ${previousPageViews}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting previous period page views for entity ${input.entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let previousUniqueVisitors = 0;
|
||||
try {
|
||||
const fullPreviousUniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
);
|
||||
previousUniqueVisitors = fullPreviousUniqueVisitors - uniqueVisitors; // This calculates change, not just previous period's total
|
||||
this.logger.debug(`Previous period full unique visitors: ${fullPreviousUniqueVisitors}, change: ${previousUniqueVisitors}`);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting previous period unique visitors for entity ${input.entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result: EntityAnalyticsOutput = {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: {
|
||||
totalPageViews,
|
||||
uniqueVisitors,
|
||||
sponsorClicks,
|
||||
engagementScore,
|
||||
trustIndicator,
|
||||
exposureValue,
|
||||
},
|
||||
trends: {
|
||||
pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews),
|
||||
uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors),
|
||||
engagementChange: 0, // Would need historical engagement data
|
||||
},
|
||||
period: {
|
||||
start: since,
|
||||
end: now,
|
||||
label: this.formatPeriodLabel(since, now),
|
||||
},
|
||||
};
|
||||
this.logger.info(`Successfully retrieved analytics for entity ${input.entityId}.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private getPeriodStartDate(now: Date, period: SnapshotPeriod): Date {
|
||||
this.logger.debug(`Calculating period start date for "${period}" from ${now.toISOString()}`);
|
||||
const start = new Date(now);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
this.logger.debug(`Period start date calculated: ${start.toISOString()}`);
|
||||
return start;
|
||||
}
|
||||
|
||||
private getPreviousPeriodStart(currentStart: Date, period: SnapshotPeriod): Date {
|
||||
this.logger.debug(`Calculating previous period start date for "${period}" from ${currentStart.toISOString()}`);
|
||||
const start = new Date(currentStart);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
this.logger.debug(`Previous period start date calculated: ${start.toISOString()}`);
|
||||
return start;
|
||||
}
|
||||
|
||||
private async calculateEngagementScore(entityId: string, since: Date): Promise<number> {
|
||||
this.logger.debug(`Calculating engagement score for entity ${entityId} since ${since.toISOString()}`);
|
||||
let sponsorClicks = 0;
|
||||
try {
|
||||
sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(entityId, since);
|
||||
this.logger.debug(`Sponsor clicks for engagement score for entity ${entityId}: ${sponsorClicks}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting sponsor clicks for engagement score for entity ${entityId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
const score = sponsorClicks * 10; // Weighted score
|
||||
this.logger.debug(`Calculated engagement score for entity ${entityId}: ${score}`);
|
||||
return score;
|
||||
}
|
||||
|
||||
private determineTrustIndicator(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
engagementScore: number
|
||||
): 'high' | 'medium' | 'low' {
|
||||
this.logger.debug(`Determining trust indicator with pageViews: ${pageViews}, uniqueVisitors: ${uniqueVisitors}, engagementScore: ${engagementScore}`);
|
||||
const engagementRate = pageViews > 0 ? engagementScore / pageViews : 0;
|
||||
const returningVisitorRate = pageViews > 0 ? (pageViews - uniqueVisitors) / pageViews : 0;
|
||||
this.logger.debug(`Engagement rate: ${engagementRate}, Returning visitor rate: ${returningVisitorRate}`);
|
||||
|
||||
if (engagementRate > 0.1 && returningVisitorRate > 0.3) {
|
||||
return 'high';
|
||||
}
|
||||
if (engagementRate > 0.05 || returningVisitorRate > 0.1) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private calculateExposureValue(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
sponsorClicks: number
|
||||
): number {
|
||||
this.logger.debug(`Calculating exposure value with pageViews: ${pageViews}, uniqueVisitors: ${uniqueVisitors}, sponsorClicks: ${sponsorClicks}`);
|
||||
// Simple exposure value calculation (could be monetized)
|
||||
const exposure = (pageViews * 0.01) + (uniqueVisitors * 0.05) + (sponsorClicks * 0.50);
|
||||
this.logger.debug(`Calculated exposure value: ${exposure}`);
|
||||
return exposure;
|
||||
}
|
||||
|
||||
private calculatePercentageChange(previous: number, current: number): number {
|
||||
this.logger.debug(`Calculating percentage change from previous: ${previous} to current: ${current}`);
|
||||
if (previous === 0) {
|
||||
const change = current > 0 ? 100 : 0;
|
||||
this.logger.debug(`Percentage change (previous was 0): ${change}%`);
|
||||
return change;
|
||||
}
|
||||
const change = Math.round(((current - previous) / previous) * 100);
|
||||
this.logger.debug(`Percentage change: ${change}%`);
|
||||
return change;
|
||||
}
|
||||
|
||||
private formatPeriodLabel(start: Date, end: Date): string {
|
||||
this.logger.debug(`Formatting period label from ${start.toISOString()} to ${end.toISOString()}`);
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
const label = `${formatter.format(start)} - ${formatter.format(end)}`;
|
||||
this.logger.debug(`Formatted period label: "${label}"`);
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Use Case: RecordEngagementUseCase
|
||||
*
|
||||
* Records an engagement event when a visitor interacts with an entity.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
|
||||
export interface RecordEngagementInput {
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface RecordEngagementOutput {
|
||||
eventId: string;
|
||||
engagementWeight: number;
|
||||
}
|
||||
|
||||
export class RecordEngagementUseCase
|
||||
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
|
||||
constructor(
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
this.logger.debug('Executing RecordEngagementUseCase', { input });
|
||||
try {
|
||||
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
|
||||
id: eventId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorType: input.actorType,
|
||||
sessionId: input.sessionId,
|
||||
};
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
...baseProps,
|
||||
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
|
||||
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
||||
});
|
||||
|
||||
await this.engagementRepository.save(event);
|
||||
this.logger.info('Engagement recorded successfully', { eventId, input });
|
||||
|
||||
return {
|
||||
eventId,
|
||||
engagementWeight: event.getEngagementWeight(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Error recording engagement', error, { input });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Use Case: RecordPageViewUseCase
|
||||
*
|
||||
* Records a page view event when a visitor accesses an entity page.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { PageView } from '../../domain/entities/PageView';
|
||||
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
|
||||
export interface RecordPageViewInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RecordPageViewOutput {
|
||||
pageViewId: string;
|
||||
}
|
||||
|
||||
export class RecordPageViewUseCase
|
||||
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
this.logger.debug('Executing RecordPageViewUseCase', { input });
|
||||
try {
|
||||
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
|
||||
id: pageViewId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
visitorType: input.visitorType,
|
||||
sessionId: input.sessionId,
|
||||
};
|
||||
|
||||
const pageView = PageView.create({
|
||||
...baseProps,
|
||||
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
|
||||
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
|
||||
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
|
||||
...(input.country !== undefined ? { country: input.country } : {}),
|
||||
});
|
||||
|
||||
await this.pageViewRepository.save(pageView);
|
||||
this.logger.info('Page view recorded successfully', { pageViewId, input });
|
||||
return { pageViewId };
|
||||
} catch (error) {
|
||||
this.logger.error('Error recording page view', error, { input });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
core/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
149
core/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Domain Entity: AnalyticsSnapshot
|
||||
*
|
||||
* Aggregated analytics data for a specific entity over a time period.
|
||||
* Pre-calculated metrics for sponsor dashboard and entity analytics.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type {
|
||||
AnalyticsSnapshotProps,
|
||||
AnalyticsMetrics,
|
||||
SnapshotEntityType,
|
||||
SnapshotPeriod,
|
||||
} from '../types/AnalyticsSnapshot';
|
||||
export type { SnapshotEntityType, SnapshotPeriod } from '../types/AnalyticsSnapshot';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
|
||||
export class AnalyticsSnapshot implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly entityType: SnapshotEntityType;
|
||||
readonly period: SnapshotPeriod;
|
||||
readonly startDate: Date;
|
||||
readonly endDate: Date;
|
||||
readonly metrics: AnalyticsMetrics;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private readonly entityIdVo: AnalyticsEntityId;
|
||||
|
||||
private constructor(props: AnalyticsSnapshotProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
|
||||
this.period = props.period;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.metrics = props.metrics;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
get entityId(): string {
|
||||
return this.entityIdVo.value;
|
||||
}
|
||||
|
||||
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
|
||||
this.validate(props);
|
||||
|
||||
return new AnalyticsSnapshot({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
static createEmpty(
|
||||
id: string,
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): AnalyticsSnapshot {
|
||||
return new AnalyticsSnapshot({
|
||||
id,
|
||||
entityType,
|
||||
entityId,
|
||||
period,
|
||||
startDate,
|
||||
endDate,
|
||||
metrics: {
|
||||
pageViews: 0,
|
||||
uniqueVisitors: 0,
|
||||
avgSessionDuration: 0,
|
||||
bounceRate: 0,
|
||||
engagementScore: 0,
|
||||
sponsorClicks: 0,
|
||||
sponsorUrlClicks: 0,
|
||||
socialShares: 0,
|
||||
leagueJoins: 0,
|
||||
raceRegistrations: 0,
|
||||
exposureValue: 0,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<AnalyticsSnapshotProps, 'createdAt'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('AnalyticsSnapshot ID is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('AnalyticsSnapshot entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('AnalyticsSnapshot entityId is required');
|
||||
}
|
||||
|
||||
if (!props.period) {
|
||||
throw new Error('AnalyticsSnapshot period is required');
|
||||
}
|
||||
|
||||
if (props.endDate < props.startDate) {
|
||||
throw new Error('AnalyticsSnapshot endDate must be after startDate');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exposure score for sponsors (weighted combination of metrics)
|
||||
*/
|
||||
calculateExposureScore(): number {
|
||||
const { pageViews, uniqueVisitors, sponsorClicks, sponsorUrlClicks, socialShares } = this.metrics;
|
||||
|
||||
return (
|
||||
pageViews * 1 +
|
||||
uniqueVisitors * 2 +
|
||||
sponsorClicks * 10 +
|
||||
sponsorUrlClicks * 25 +
|
||||
socialShares * 5
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trust indicator based on engagement quality
|
||||
*/
|
||||
getTrustIndicator(): 'high' | 'medium' | 'low' {
|
||||
const { bounceRate, avgSessionDuration, engagementScore } = this.metrics;
|
||||
|
||||
if (bounceRate < 30 && avgSessionDuration > 120000 && engagementScore > 50) {
|
||||
return 'high';
|
||||
}
|
||||
if (bounceRate < 60 && avgSessionDuration > 30000 && engagementScore > 20) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period comparison label
|
||||
*/
|
||||
getPeriodLabel(): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: this.period === 'monthly' ? 'numeric' : undefined
|
||||
});
|
||||
|
||||
return `${formatter.format(this.startDate)} - ${formatter.format(this.endDate)}`;
|
||||
}
|
||||
}
|
||||
111
core/analytics/domain/entities/EngagementEvent.ts
Normal file
111
core/analytics/domain/entities/EngagementEvent.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Domain Entity: EngagementEvent
|
||||
*
|
||||
* Represents user interactions beyond page views.
|
||||
* Tracks clicks, downloads, sign-ups, and other engagement actions.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type {
|
||||
EngagementAction,
|
||||
EngagementEntityType,
|
||||
EngagementEventProps,
|
||||
} from '../types/EngagementEvent';
|
||||
|
||||
export type { EngagementAction, EngagementEntityType } from '../types/EngagementEvent';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
|
||||
export class EngagementEvent implements IEntity<string> {
|
||||
readonly id: string;
|
||||
readonly action: EngagementAction;
|
||||
readonly entityType: EngagementEntityType;
|
||||
readonly actorId: string | undefined;
|
||||
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
readonly sessionId: string;
|
||||
readonly metadata: Record<string, string | number | boolean> | undefined;
|
||||
readonly timestamp: Date;
|
||||
|
||||
private readonly entityIdVo: AnalyticsEntityId;
|
||||
|
||||
private constructor(props: EngagementEventProps) {
|
||||
this.id = props.id;
|
||||
this.action = props.action;
|
||||
this.entityType = props.entityType;
|
||||
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
|
||||
this.actorId = props.actorId;
|
||||
this.actorType = props.actorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.metadata = props.metadata;
|
||||
this.timestamp = props.timestamp;
|
||||
}
|
||||
|
||||
get entityId(): string {
|
||||
return this.entityIdVo.value;
|
||||
}
|
||||
|
||||
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
|
||||
this.validate(props);
|
||||
|
||||
return new EngagementEvent({
|
||||
...props,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<EngagementEventProps, 'timestamp'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('EngagementEvent ID is required');
|
||||
}
|
||||
|
||||
if (!props.action) {
|
||||
throw new Error('EngagementEvent action is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('EngagementEvent entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('EngagementEvent entityId is required');
|
||||
}
|
||||
|
||||
if (!props.sessionId || props.sessionId.trim().length === 0) {
|
||||
throw new Error('EngagementEvent sessionId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a sponsor-related engagement
|
||||
*/
|
||||
isSponsorEngagement(): boolean {
|
||||
return this.action.startsWith('click_sponsor') ||
|
||||
this.action === 'contact_sponsor' ||
|
||||
this.entityType === 'sponsor' ||
|
||||
this.entityType === 'sponsorship';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a conversion event (high-value action)
|
||||
*/
|
||||
isConversionEvent(): boolean {
|
||||
return ['join_league', 'register_race', 'click_sponsor_url', 'contact_sponsor'].includes(this.action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement weight for analytics calculations
|
||||
*/
|
||||
getEngagementWeight(): number {
|
||||
const weights: Record<EngagementAction, number> = {
|
||||
'click_sponsor_logo': 2,
|
||||
'click_sponsor_url': 5,
|
||||
'download_livery_pack': 3,
|
||||
'join_league': 10,
|
||||
'register_race': 8,
|
||||
'view_standings': 1,
|
||||
'view_schedule': 1,
|
||||
'share_social': 4,
|
||||
'contact_sponsor': 15,
|
||||
};
|
||||
return weights[this.action] || 1;
|
||||
}
|
||||
}
|
||||
131
core/analytics/domain/entities/PageView.ts
Normal file
131
core/analytics/domain/entities/PageView.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Domain Entity: PageView
|
||||
*
|
||||
* Represents a single page view event for analytics tracking.
|
||||
* Captures visitor interactions with leagues, drivers, teams, races.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { EntityType, VisitorType, PageViewProps } from '../types/PageView';
|
||||
|
||||
export type { EntityType, VisitorType } from '../types/PageView';
|
||||
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
|
||||
import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId';
|
||||
import { PageViewId } from '../value-objects/PageViewId';
|
||||
|
||||
export class PageView implements IEntity<string> {
|
||||
readonly entityType: EntityType;
|
||||
readonly visitorId: string | undefined;
|
||||
readonly visitorType: VisitorType;
|
||||
readonly referrer: string | undefined;
|
||||
readonly userAgent: string | undefined;
|
||||
readonly country: string | undefined;
|
||||
readonly timestamp: Date;
|
||||
readonly durationMs: number | undefined;
|
||||
|
||||
private readonly idVo: PageViewId;
|
||||
private readonly entityIdVo: AnalyticsEntityId;
|
||||
private readonly sessionIdVo: AnalyticsSessionId;
|
||||
|
||||
private constructor(props: PageViewProps) {
|
||||
this.idVo = PageViewId.create(props.id);
|
||||
this.entityType = props.entityType;
|
||||
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
|
||||
this.visitorId = props.visitorId;
|
||||
this.visitorType = props.visitorType;
|
||||
this.sessionIdVo = AnalyticsSessionId.create(props.sessionId);
|
||||
this.referrer = props.referrer;
|
||||
this.userAgent = props.userAgent;
|
||||
this.country = props.country;
|
||||
this.timestamp = props.timestamp;
|
||||
this.durationMs = props.durationMs;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.idVo.value;
|
||||
}
|
||||
|
||||
get entityId(): string {
|
||||
return this.entityIdVo.value;
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this.sessionIdVo.value;
|
||||
}
|
||||
|
||||
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
|
||||
this.validate(props);
|
||||
|
||||
const baseProps: PageViewProps = {
|
||||
id: props.id,
|
||||
entityType: props.entityType,
|
||||
entityId: props.entityId,
|
||||
visitorType: props.visitorType,
|
||||
sessionId: props.sessionId,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
...(props.visitorId !== undefined ? { visitorId: props.visitorId } : {}),
|
||||
...(props.referrer !== undefined ? { referrer: props.referrer } : {}),
|
||||
...(props.userAgent !== undefined ? { userAgent: props.userAgent } : {}),
|
||||
...(props.country !== undefined ? { country: props.country } : {}),
|
||||
...(props.durationMs !== undefined ? { durationMs: props.durationMs } : {}),
|
||||
};
|
||||
|
||||
return new PageView(baseProps);
|
||||
}
|
||||
|
||||
private static validate(props: Omit<PageViewProps, 'timestamp'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('PageView ID is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('PageView entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('PageView entityId is required');
|
||||
}
|
||||
|
||||
if (!props.sessionId || props.sessionId.trim().length === 0) {
|
||||
throw new Error('PageView sessionId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update duration when visitor leaves page
|
||||
*/
|
||||
withDuration(durationMs: number): PageView {
|
||||
if (durationMs < 0) {
|
||||
throw new Error('Duration must be non-negative');
|
||||
}
|
||||
|
||||
return PageView.create({
|
||||
id: this.id,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
visitorType: this.visitorType,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.timestamp,
|
||||
...(this.visitorId !== undefined ? { visitorId: this.visitorId } : {}),
|
||||
...(this.referrer !== undefined ? { referrer: this.referrer } : {}),
|
||||
...(this.userAgent !== undefined ? { userAgent: this.userAgent } : {}),
|
||||
...(this.country !== undefined ? { country: this.country } : {}),
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a meaningful view (not a bounce)
|
||||
*/
|
||||
isMeaningfulView(): boolean {
|
||||
return this.durationMs !== undefined && this.durationMs >= 5000; // 5+ seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if view came from external source
|
||||
*/
|
||||
isExternalReferral(): boolean {
|
||||
if (!this.referrer) return false;
|
||||
return !this.referrer.includes('gridpilot');
|
||||
}
|
||||
}
|
||||
6
core/analytics/domain/ports/ILogger.ts
Normal file
6
core/analytics/domain/ports/ILogger.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ILogger {
|
||||
debug(message: string, ...args: any[]): void;
|
||||
info(message: string, ...args: any[]): void;
|
||||
warn(message: string, ...args: any[]): void;
|
||||
error(message: string, ...args: any[]): void;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Repository Interface: IAnalyticsSnapshotRepository
|
||||
*
|
||||
* Defines persistence operations for AnalyticsSnapshot entities.
|
||||
*/
|
||||
|
||||
import type { AnalyticsSnapshot, SnapshotPeriod, SnapshotEntityType } from '../entities/AnalyticsSnapshot';
|
||||
|
||||
export interface IAnalyticsSnapshotRepository {
|
||||
save(snapshot: AnalyticsSnapshot): Promise<void>;
|
||||
findById(id: string): Promise<AnalyticsSnapshot | null>;
|
||||
findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]>;
|
||||
findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null>;
|
||||
findLatest(entityType: SnapshotEntityType, entityId: string, period: SnapshotPeriod): Promise<AnalyticsSnapshot | null>;
|
||||
getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]>;
|
||||
}
|
||||
17
core/analytics/domain/repositories/IEngagementRepository.ts
Normal file
17
core/analytics/domain/repositories/IEngagementRepository.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: IEngagementRepository
|
||||
*
|
||||
* Defines persistence operations for EngagementEvent entities.
|
||||
*/
|
||||
|
||||
import type { EngagementEvent, EngagementAction, EngagementEntityType } from '../entities/EngagementEvent';
|
||||
|
||||
export interface IEngagementRepository {
|
||||
save(event: EngagementEvent): Promise<void>;
|
||||
findById(id: string): Promise<EngagementEvent | null>;
|
||||
findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]>;
|
||||
findByAction(action: EngagementAction): Promise<EngagementEvent[]>;
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]>;
|
||||
countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number>;
|
||||
getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number>;
|
||||
}
|
||||
17
core/analytics/domain/repositories/IPageViewRepository.ts
Normal file
17
core/analytics/domain/repositories/IPageViewRepository.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: IPageViewRepository
|
||||
*
|
||||
* Defines persistence operations for PageView entities.
|
||||
*/
|
||||
|
||||
import type { PageView, EntityType } from '../entities/PageView';
|
||||
|
||||
export interface IPageViewRepository {
|
||||
save(pageView: PageView): Promise<void>;
|
||||
findById(id: string): Promise<PageView | null>;
|
||||
findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]>;
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]>;
|
||||
findBySession(sessionId: string): Promise<PageView[]>;
|
||||
countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
|
||||
countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
|
||||
}
|
||||
35
core/analytics/domain/types/AnalyticsSnapshot.ts
Normal file
35
core/analytics/domain/types/AnalyticsSnapshot.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Domain Types: AnalyticsSnapshot
|
||||
*
|
||||
* Pure type/config definitions used by the AnalyticsSnapshot entity.
|
||||
* Kept in domain/types so domain/entities contains only entity classes.
|
||||
*/
|
||||
|
||||
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
|
||||
export interface AnalyticsMetrics {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
avgSessionDuration: number;
|
||||
bounceRate: number;
|
||||
engagementScore: number;
|
||||
sponsorClicks: number;
|
||||
sponsorUrlClicks: number;
|
||||
socialShares: number;
|
||||
leagueJoins: number;
|
||||
raceRegistrations: number;
|
||||
exposureValue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsSnapshotProps {
|
||||
id: string;
|
||||
entityType: SnapshotEntityType;
|
||||
entityId: string;
|
||||
period: SnapshotPeriod;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
metrics: AnalyticsMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
37
core/analytics/domain/types/EngagementEvent.ts
Normal file
37
core/analytics/domain/types/EngagementEvent.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Domain Types: EngagementEvent
|
||||
*
|
||||
* Pure type/config definitions used by the EngagementEvent entity.
|
||||
* Kept in domain/types so domain/entities contains only entity classes.
|
||||
*/
|
||||
|
||||
export type EngagementAction =
|
||||
| 'click_sponsor_logo'
|
||||
| 'click_sponsor_url'
|
||||
| 'download_livery_pack'
|
||||
| 'join_league'
|
||||
| 'register_race'
|
||||
| 'view_standings'
|
||||
| 'view_schedule'
|
||||
| 'share_social'
|
||||
| 'contact_sponsor';
|
||||
|
||||
export type EngagementEntityType =
|
||||
| 'league'
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'race'
|
||||
| 'sponsor'
|
||||
| 'sponsorship';
|
||||
|
||||
export interface EngagementEventProps {
|
||||
id: string;
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
timestamp: Date;
|
||||
}
|
||||
34
core/analytics/domain/types/PageView.ts
Normal file
34
core/analytics/domain/types/PageView.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Domain Types: PageView
|
||||
*
|
||||
* Pure type/config definitions used by the PageView entity.
|
||||
* Kept in domain/types so domain/entities contains only entity classes.
|
||||
*/
|
||||
|
||||
export enum EntityType {
|
||||
LEAGUE = 'league',
|
||||
DRIVER = 'driver',
|
||||
TEAM = 'team',
|
||||
RACE = 'race',
|
||||
SPONSOR = 'sponsor',
|
||||
}
|
||||
|
||||
export enum VisitorType {
|
||||
ANONYMOUS = 'anonymous',
|
||||
DRIVER = 'driver',
|
||||
SPONSOR = 'sponsor',
|
||||
}
|
||||
|
||||
export interface PageViewProps {
|
||||
id: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
timestamp: Date;
|
||||
durationMs?: number;
|
||||
}
|
||||
37
core/analytics/domain/value-objects/AnalyticsEntityId.ts
Normal file
37
core/analytics/domain/value-objects/AnalyticsEntityId.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface AnalyticsEntityIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: AnalyticsEntityId
|
||||
*
|
||||
* Represents the ID of an entity (league, driver, team, race, sponsor)
|
||||
* within the analytics bounded context.
|
||||
*/
|
||||
export class AnalyticsEntityId implements IValueObject<AnalyticsEntityIdProps> {
|
||||
public readonly props: AnalyticsEntityIdProps;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
static create(raw: string): AnalyticsEntityId {
|
||||
const value = raw.trim();
|
||||
|
||||
if (!value) {
|
||||
throw new Error('AnalyticsEntityId must be a non-empty string');
|
||||
}
|
||||
|
||||
return new AnalyticsEntityId(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<AnalyticsEntityIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
36
core/analytics/domain/value-objects/AnalyticsSessionId.ts
Normal file
36
core/analytics/domain/value-objects/AnalyticsSessionId.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface AnalyticsSessionIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: AnalyticsSessionId
|
||||
*
|
||||
* Represents an analytics session identifier within the analytics bounded context.
|
||||
*/
|
||||
export class AnalyticsSessionId implements IValueObject<AnalyticsSessionIdProps> {
|
||||
public readonly props: AnalyticsSessionIdProps;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
static create(raw: string): AnalyticsSessionId {
|
||||
const value = raw.trim();
|
||||
|
||||
if (!value) {
|
||||
throw new Error('AnalyticsSessionId must be a non-empty string');
|
||||
}
|
||||
|
||||
return new AnalyticsSessionId(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<AnalyticsSessionIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
36
core/analytics/domain/value-objects/PageViewId.ts
Normal file
36
core/analytics/domain/value-objects/PageViewId.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface PageViewIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: PageViewId
|
||||
*
|
||||
* Represents the identifier of a PageView within the analytics bounded context.
|
||||
*/
|
||||
export class PageViewId implements IValueObject<PageViewIdProps> {
|
||||
public readonly props: PageViewIdProps;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
static create(raw: string): PageViewId {
|
||||
const value = raw.trim();
|
||||
|
||||
if (!value) {
|
||||
throw new Error('PageViewId must be a non-empty string');
|
||||
}
|
||||
|
||||
return new PageViewId(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<PageViewIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
26
core/analytics/index.ts
Normal file
26
core/analytics/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @gridpilot/analytics
|
||||
*
|
||||
* Analytics bounded context - tracks page views, engagement events,
|
||||
* and generates analytics snapshots for sponsor exposure metrics.
|
||||
*/
|
||||
|
||||
// Domain entities
|
||||
export * from './domain/entities/PageView';
|
||||
export * from './domain/entities/EngagementEvent';
|
||||
export * from './domain/entities/AnalyticsSnapshot';
|
||||
|
||||
// Domain repositories
|
||||
export * from './domain/repositories/IPageViewRepository';
|
||||
export * from './domain/repositories/IEngagementRepository';
|
||||
export * from './domain/repositories/IAnalyticsSnapshotRepository';
|
||||
|
||||
// Application use cases
|
||||
export * from './application/use-cases/RecordPageViewUseCase';
|
||||
export * from './application/use-cases/RecordEngagementUseCase';
|
||||
export * from './application/use-cases/GetEntityAnalyticsQuery';
|
||||
|
||||
// Infrastructure
|
||||
export * from './infrastructure/repositories/InMemoryPageViewRepository';
|
||||
export * from './infrastructure/repositories/InMemoryEngagementRepository';
|
||||
export * from './infrastructure/repositories/InMemoryAnalyticsSnapshotRepository';
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryAnalyticsSnapshotRepository
|
||||
*
|
||||
* In-memory implementation of IAnalyticsSnapshotRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import { AnalyticsSnapshot, type SnapshotPeriod, type SnapshotEntityType } from '../../domain/entities/AnalyticsSnapshot';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
|
||||
private snapshots: Map<string, AnalyticsSnapshot> = new Map();
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryAnalyticsSnapshotRepository initialized.');
|
||||
}
|
||||
|
||||
async save(snapshot: AnalyticsSnapshot): Promise<void> {
|
||||
this.logger.debug(`Saving AnalyticsSnapshot: ${snapshot.id}`);
|
||||
try {
|
||||
this.snapshots.set(snapshot.id, snapshot);
|
||||
this.logger.info(`AnalyticsSnapshot ${snapshot.id} saved successfully.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving AnalyticsSnapshot ${snapshot.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AnalyticsSnapshot | null> {
|
||||
this.logger.debug(`Finding AnalyticsSnapshot by ID: ${id}`);
|
||||
try {
|
||||
const snapshot = this.snapshots.get(id) ?? null;
|
||||
if (snapshot) {
|
||||
this.logger.info(`Found AnalyticsSnapshot with ID: ${id}`);
|
||||
} else {
|
||||
this.logger.warn(`AnalyticsSnapshot with ID ${id} not found.`);
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding AnalyticsSnapshot by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
|
||||
this.logger.debug(`Finding AnalyticsSnapshots by Entity: ${entityType}, ${entityId}`);
|
||||
try {
|
||||
const snapshots = Array.from(this.snapshots.values()).filter(
|
||||
s => s.entityType === entityType && s.entityId === entityId
|
||||
);
|
||||
this.logger.info(`Found ${snapshots.length} AnalyticsSnapshots for entity ${entityId}.`);
|
||||
return snapshots;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding AnalyticsSnapshots for entity ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
this.logger.debug(`Finding AnalyticsSnapshot by Period for entity ${entityId}, period ${period}, from ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||
try {
|
||||
const snapshot = Array.from(this.snapshots.values()).find(
|
||||
s => s.entityType === entityType &&
|
||||
s.entityId === entityId &&
|
||||
s.period === period &&
|
||||
s.startDate >= startDate &&
|
||||
s.endDate <= endDate
|
||||
) ?? null;
|
||||
if (snapshot) {
|
||||
this.logger.info(`Found AnalyticsSnapshot for entity ${entityId}, period ${period}.`);
|
||||
} else {
|
||||
this.logger.warn(`No AnalyticsSnapshot found for entity ${entityId}, period ${period}, from ${startDate.toISOString()} to ${endDate.toISOString()}.`);
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding AnalyticsSnapshot by period for entity ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findLatest(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
this.logger.debug(`Finding latest AnalyticsSnapshot for entity ${entityId}, period ${period}`);
|
||||
try {
|
||||
const matching = Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime());
|
||||
|
||||
const snapshot = matching[0] ?? null;
|
||||
if (snapshot) {
|
||||
this.logger.info(`Found latest AnalyticsSnapshot for entity ${entityId}, period ${period}.`);
|
||||
} else {
|
||||
this.logger.warn(`No latest AnalyticsSnapshot found for entity ${entityId}, period ${period}.`);
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding latest AnalyticsSnapshot for entity ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]> {
|
||||
this.logger.debug(`Getting historical AnalyticsSnapshots for entity ${entityId}, period ${period}, limit ${limit}`);
|
||||
try {
|
||||
const snapshots = Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime())
|
||||
.slice(0, limit);
|
||||
this.logger.info(`Found ${snapshots.length} historical AnalyticsSnapshots for entity ${entityId}, period ${period}.`);
|
||||
return snapshots;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting historical AnalyticsSnapshots for entity ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(snapshots: AnalyticsSnapshot[]): void {
|
||||
snapshots.forEach(s => this.snapshots.set(s.id, s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryEngagementRepository
|
||||
*
|
||||
* In-memory implementation of IEngagementRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryEngagementRepository implements IEngagementRepository {
|
||||
private events: Map<string, EngagementEvent> = new Map();
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryEngagementRepository initialized.');
|
||||
}
|
||||
|
||||
async save(event: EngagementEvent): Promise<void> {
|
||||
this.logger.debug(`Attempting to save engagement event: ${event.id}`);
|
||||
try {
|
||||
this.events.set(event.id, event);
|
||||
this.logger.info(`Successfully saved engagement event: ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving engagement event ${event.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EngagementEvent | null> {
|
||||
this.logger.debug(`Attempting to find engagement event by ID: ${id}`);
|
||||
try {
|
||||
const event = this.events.get(id) ?? null;
|
||||
if (event) {
|
||||
this.logger.info(`Found engagement event by ID: ${id}`);
|
||||
} else {
|
||||
this.logger.warn(`Engagement event not found for ID: ${id}`);
|
||||
// The original was info, but if a requested ID is not found that's more of a warning than an info.
|
||||
}
|
||||
return event;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding engagement event by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
|
||||
this.logger.debug(`Attempting to find engagement events for entityType: ${entityType}, entityId: ${entityId}`);
|
||||
try {
|
||||
const events = Array.from(this.events.values()).filter(
|
||||
e => e.entityType === entityType && e.entityId === entityId
|
||||
);
|
||||
this.logger.info(`Found ${events.length} engagement events for entityType: ${entityType}, entityId: ${entityId}`);
|
||||
return events;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding engagement events by entity ID ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
|
||||
this.logger.debug(`Attempting to find engagement events by action: ${action}`);
|
||||
try {
|
||||
const events = Array.from(this.events.values()).filter(
|
||||
e => e.action === action
|
||||
);
|
||||
this.logger.info(`Found ${events.length} engagement events for action: ${action}`);
|
||||
return events;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding engagement events by action ${action}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
|
||||
this.logger.debug(`Attempting to find engagement events by date range: ${startDate.toISOString()} - ${endDate.toISOString()}`);
|
||||
try {
|
||||
const events = Array.from(this.events.values()).filter(
|
||||
e => e.timestamp >= startDate && e.timestamp <= endDate
|
||||
);
|
||||
this.logger.info(`Found ${events.length} engagement events for date range.`);
|
||||
return events;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding engagement events by date range:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
|
||||
this.logger.debug(`Attempting to count engagement events for action: ${action}, entityId: ${entityId}, since: ${since?.toISOString()}`);
|
||||
try {
|
||||
const count = Array.from(this.events.values()).filter(
|
||||
e => e.action === action &&
|
||||
(!entityId || e.entityId === entityId) &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
this.logger.info(`Counted ${count} engagement events for action: ${action}, entityId: ${entityId}`);
|
||||
return count;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting engagement events by action ${action}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
|
||||
this.logger.debug(`Attempting to get sponsor clicks for entity ID: ${entityId}, since: ${since?.toISOString()}`);
|
||||
try {
|
||||
const count = Array.from(this.events.values()).filter(
|
||||
e => e.entityId === entityId &&
|
||||
(e.action === 'click_sponsor_logo' || e.action === 'click_sponsor_url') &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
this.logger.info(`Counted ${count} sponsor clicks for entity ID: ${entityId}`);
|
||||
return count;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting sponsor clicks for entity ID ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.logger.debug('Clearing all engagement events.');
|
||||
this.events.clear();
|
||||
this.logger.info('All engagement events cleared.');
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(events: EngagementEvent[]): void {
|
||||
this.logger.debug(`Seeding ${events.length} engagement events.`);
|
||||
try {
|
||||
events.forEach(e => this.events.set(e.id, e));
|
||||
this.logger.info(`Successfully seeded ${events.length} engagement events.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error seeding engagement events:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryPageViewRepository
|
||||
*
|
||||
* In-memory implementation of IPageViewRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import { PageView, type EntityType } from '../../domain/entities/PageView';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryPageViewRepository implements IPageViewRepository {
|
||||
private pageViews: Map<string, PageView> = new Map();
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryPageViewRepository initialized.');
|
||||
}
|
||||
|
||||
async save(pageView: PageView): Promise<void> {
|
||||
this.logger.debug(`Attempting to save page view: ${pageView.id}`);
|
||||
try {
|
||||
this.pageViews.set(pageView.id, pageView);
|
||||
this.logger.info(`Successfully saved page view: ${pageView.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving page view ${pageView.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PageView | null> {
|
||||
this.logger.debug(`Attempting to find page view by ID: ${id}`);
|
||||
try {
|
||||
const pageView = this.pageViews.get(id) ?? null;
|
||||
if (pageView) {
|
||||
this.logger.info(`Found page view by ID: ${id}`);
|
||||
} else {
|
||||
this.logger.warn(`Page view not found for ID: ${id}`);
|
||||
}
|
||||
return pageView;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding page view by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
|
||||
this.logger.debug(`Attempting to find page views for entityType: ${entityType}, entityId: ${entityId}`);
|
||||
try {
|
||||
const pageViews = Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType && pv.entityId === entityId
|
||||
);
|
||||
this.logger.info(`Found ${pageViews.length} page views for entityType: ${entityType}, entityId: ${entityId}`);
|
||||
return pageViews;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding page views by entity ID ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
|
||||
this.logger.debug(`Attempting to find page views by date range: ${startDate.toISOString()} - ${endDate.toISOString()}`);
|
||||
try {
|
||||
const pageViews = Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.timestamp >= startDate && pv.timestamp <= endDate
|
||||
);
|
||||
this.logger.info(`Found ${pageViews.length} page views for date range.`);
|
||||
return pageViews;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding page views by date range:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string): Promise<PageView[]> {
|
||||
this.logger.debug(`Attempting to find page views by session ID: ${sessionId}`);
|
||||
try {
|
||||
const pageViews = Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.sessionId === sessionId
|
||||
);
|
||||
this.logger.info(`Found ${pageViews.length} page views for session ID: ${sessionId}`);
|
||||
return pageViews;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding page views by session ID ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
this.logger.debug(`Attempting to count page views for entityType: ${entityType}, entityId: ${entityId}, since: ${since?.toISOString()}`);
|
||||
try {
|
||||
const count = Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
).length;
|
||||
this.logger.info(`Counted ${count} page views for entityType: ${entityType}, entityId: ${entityId}`);
|
||||
return count;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting page views by entity ID ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
this.logger.debug(`Attempting to count unique visitors for entityType: ${entityType}, entityId: ${entityId}, since: ${since?.toISOString()}`);
|
||||
try {
|
||||
const visitors = new Set<string>();
|
||||
Array.from(this.pageViews.values())
|
||||
.filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
)
|
||||
.forEach(pv => {
|
||||
visitors.add(pv.visitorId ?? pv.sessionId);
|
||||
});
|
||||
this.logger.info(`Counted ${visitors.size} unique visitors for entityType: ${entityType}, entityId: ${entityId}`);
|
||||
return visitors.size;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting unique visitors for entity ID ${entityId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.logger.debug('Clearing all page views.');
|
||||
this.pageViews.clear();
|
||||
this.logger.info('All page views cleared.');
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(pageViews: PageView[]): void {
|
||||
this.logger.debug(`Seeding ${pageViews.length} page views.`);
|
||||
try {
|
||||
pageViews.forEach(pv => this.pageViews.set(pv.id, pv));
|
||||
this.logger.info(`Successfully seeded ${pageViews.length} page views.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error seeding page views:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
core/analytics/package.json
Normal file
14
core/analytics/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@gridpilot/analytics",
|
||||
"version": "0.1.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./domain/*": "./domain/*",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface AutomationEngineValidationResultDTO {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
5
core/automation/application/dto/AutomationResultDTO.ts
Normal file
5
core/automation/application/dto/AutomationResultDTO.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface AutomationResultDTO {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
export interface CheckoutConfirmationRequestDTO {
|
||||
price: CheckoutPrice;
|
||||
state: CheckoutState;
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
}
|
||||
8
core/automation/application/dto/CheckoutInfoDTO.ts
Normal file
8
core/automation/application/dto/CheckoutInfoDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CheckoutPrice } from '../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
export interface CheckoutInfoDTO {
|
||||
price: CheckoutPrice | null;
|
||||
state: CheckoutState;
|
||||
buttonHtml: string;
|
||||
}
|
||||
5
core/automation/application/dto/ClickResultDTO.ts
Normal file
5
core/automation/application/dto/ClickResultDTO.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface ClickResultDTO extends AutomationResultDTO {
|
||||
target: string;
|
||||
}
|
||||
6
core/automation/application/dto/FormFillResultDTO.ts
Normal file
6
core/automation/application/dto/FormFillResultDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface FormFillResultDTO extends AutomationResultDTO {
|
||||
fieldName: string;
|
||||
valueSet: string;
|
||||
}
|
||||
6
core/automation/application/dto/ModalResultDTO.ts
Normal file
6
core/automation/application/dto/ModalResultDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface ModalResultDTO extends AutomationResultDTO {
|
||||
stepId: number;
|
||||
action: string;
|
||||
}
|
||||
6
core/automation/application/dto/NavigationResultDTO.ts
Normal file
6
core/automation/application/dto/NavigationResultDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface NavigationResultDTO extends AutomationResultDTO {
|
||||
url: string;
|
||||
loadTime: number;
|
||||
}
|
||||
11
core/automation/application/dto/SessionDTO.ts
Normal file
11
core/automation/application/dto/SessionDTO.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
|
||||
export interface SessionDTO {
|
||||
sessionId: string;
|
||||
state: string;
|
||||
currentStep: number;
|
||||
config: HostedSessionConfig;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
errorMessage?: string;
|
||||
}
|
||||
7
core/automation/application/dto/WaitResultDTO.ts
Normal file
7
core/automation/application/dto/WaitResultDTO.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { AutomationResultDTO } from './AutomationResultDTO';
|
||||
|
||||
export interface WaitResultDTO extends AutomationResultDTO {
|
||||
target: string;
|
||||
waitedMs: number;
|
||||
found: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
/**
|
||||
* Port for authentication services implementing zero-knowledge login.
|
||||
*
|
||||
* GridPilot never sees, stores, or transmits user credentials.
|
||||
* Authentication is handled by opening a visible browser window where
|
||||
* the user logs in directly with iRacing. GridPilot only observes
|
||||
* URL changes to detect successful authentication.
|
||||
*/
|
||||
export interface AuthenticationServicePort {
|
||||
/**
|
||||
* Check if user has a valid session without prompting login.
|
||||
* Navigates to a protected iRacing page and checks for login redirects.
|
||||
*
|
||||
* @returns Result containing the current authentication state
|
||||
*/
|
||||
checkSession(): Promise<Result<AuthenticationState>>;
|
||||
|
||||
/**
|
||||
* Open browser for user to login manually.
|
||||
* The browser window is visible so user can verify they're on the real iRacing site.
|
||||
* GridPilot waits for URL change indicating successful login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
initiateLogin(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Clear the persistent session (logout).
|
||||
* Removes stored browser context and cookies.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
clearSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get current authentication state.
|
||||
* Returns cached state without making network requests.
|
||||
*
|
||||
* @returns The current AuthenticationState
|
||||
*/
|
||||
getState(): AuthenticationState;
|
||||
|
||||
/**
|
||||
* Validate session with server-side check.
|
||||
* Makes a lightweight HTTP request to verify cookies are still valid on the server.
|
||||
*
|
||||
* @returns Result containing true if server confirms validity, false otherwise
|
||||
*/
|
||||
validateServerSide(): Promise<Result<boolean>>;
|
||||
|
||||
/**
|
||||
* Refresh session state from cookie store.
|
||||
* Re-reads cookies and updates internal state without server validation.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
refreshSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get session expiry date.
|
||||
* Returns the expiry time extracted from session cookies.
|
||||
*
|
||||
* @returns Result containing the expiry Date or null if no expiration
|
||||
*/
|
||||
getSessionExpiry(): Promise<Result<Date | null>>;
|
||||
|
||||
/**
|
||||
* Verify browser page shows authenticated state.
|
||||
* Checks page content for authentication indicators.
|
||||
*/
|
||||
verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>>;
|
||||
}
|
||||
11
core/automation/application/ports/AutomationEnginePort.ts
Normal file
11
core/automation/application/ports/AutomationEnginePort.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { AutomationEngineValidationResultDTO } from '../dto/AutomationEngineValidationResultDTO';
|
||||
import type { IBrowserAutomation } from './ScreenAutomationPort';
|
||||
|
||||
export interface AutomationEnginePort {
|
||||
validateConfiguration(config: HostedSessionConfig): Promise<AutomationEngineValidationResultDTO>;
|
||||
executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void>;
|
||||
stopAutomation(): void;
|
||||
readonly browserAutomation: IBrowserAutomation;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AutomationEvent = {
|
||||
actionId?: string
|
||||
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
|
||||
timestamp: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface AutomationEventPublisherPort {
|
||||
publish(event: AutomationEvent): Promise<void>
|
||||
}
|
||||
5
core/automation/application/ports/AutomationResults.ts
Normal file
5
core/automation/application/ports/AutomationResults.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface AutomationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
|
||||
import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO';
|
||||
|
||||
export interface CheckoutConfirmationPort {
|
||||
requestCheckoutConfirmation(
|
||||
request: CheckoutConfirmationRequestDTO
|
||||
): Promise<Result<CheckoutConfirmation>>;
|
||||
}
|
||||
7
core/automation/application/ports/CheckoutServicePort.ts
Normal file
7
core/automation/application/ports/CheckoutServicePort.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO';
|
||||
|
||||
export interface CheckoutServicePort {
|
||||
extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>>;
|
||||
proceedWithCheckout(): Promise<Result<void>>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AutomationEvent = {
|
||||
actionId?: string
|
||||
type: 'panel-attached'|'modal-opened'|'action-started'|'action-complete'|'action-failed'|'panel-missing'
|
||||
timestamp: number
|
||||
payload?: any
|
||||
}
|
||||
|
||||
export interface IAutomationEventPublisher {
|
||||
publish(event: AutomationEvent): Promise<void>
|
||||
}
|
||||
7
core/automation/application/ports/ILogger.ts
Normal file
7
core/automation/application/ports/ILogger.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ILogger {
|
||||
debug(message: string, context?: Record<string, any>): void;
|
||||
info(message: string, context?: Record<string, any>): void;
|
||||
warn(message: string, context?: Record<string, any>): void;
|
||||
error(message: string, error?: Error, context?: Record<string, any>): void;
|
||||
verbose?(message: string, context?: Record<string, any>): void;
|
||||
}
|
||||
7
core/automation/application/ports/IOverlaySyncPort.ts
Normal file
7
core/automation/application/ports/IOverlaySyncPort.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
||||
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||
|
||||
export interface IOverlaySyncPort {
|
||||
startAction(action: OverlayAction): Promise<ActionAck>
|
||||
cancelAction(actionId: string): Promise<void>
|
||||
}
|
||||
17
core/automation/application/ports/LoggerContext.ts
Normal file
17
core/automation/application/ports/LoggerContext.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Contextual metadata attached to log entries
|
||||
*/
|
||||
export interface LogContext {
|
||||
/** Unique session identifier for correlation */
|
||||
sessionId?: string;
|
||||
/** Current automation step (1-18) */
|
||||
stepId?: number;
|
||||
/** Step name for human readability */
|
||||
stepName?: string;
|
||||
/** Adapter or component name */
|
||||
adapter?: string;
|
||||
/** Operation duration in milliseconds */
|
||||
durationMs?: number;
|
||||
/** Additional arbitrary metadata */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
4
core/automation/application/ports/LoggerLogLevel.ts
Normal file
4
core/automation/application/ports/LoggerLogLevel.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Log levels in order of severity (lowest to highest)
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||
16
core/automation/application/ports/LoggerPort.ts
Normal file
16
core/automation/application/ports/LoggerPort.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { LogLevel } from './LoggerLogLevel';
|
||||
import type { LogContext } from './LoggerContext';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
/**
|
||||
* LoggerPort - Port interface for application-layer logging.
|
||||
*/
|
||||
export interface LoggerPort extends ILogger {
|
||||
debug(message: string, context?: LogContext): void;
|
||||
info(message: string, context?: LogContext): void;
|
||||
warn(message: string, context?: LogContext): void;
|
||||
error(message: string, error?: Error, context?: LogContext): void;
|
||||
fatal(message: string, error?: Error, context?: LogContext): void;
|
||||
child(context: LogContext): LoggerPort;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
7
core/automation/application/ports/OverlaySyncPort.ts
Normal file
7
core/automation/application/ports/OverlaySyncPort.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type OverlayAction = { id: string; label: string; meta?: Record<string, unknown>; timeoutMs?: number }
|
||||
export type ActionAck = { id: string; status: 'confirmed' | 'tentative' | 'failed'; reason?: string }
|
||||
|
||||
export interface OverlaySyncPort {
|
||||
startAction(action: OverlayAction): Promise<ActionAck>
|
||||
cancelAction(actionId: string): Promise<void>
|
||||
}
|
||||
62
core/automation/application/ports/ScreenAutomationPort.ts
Normal file
62
core/automation/application/ports/ScreenAutomationPort.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { NavigationResultDTO } from '../dto/NavigationResultDTO';
|
||||
import type { ClickResultDTO } from '../dto/ClickResultDTO';
|
||||
import type { WaitResultDTO } from '../dto/WaitResultDTO';
|
||||
import type { ModalResultDTO } from '../dto/ModalResultDTO';
|
||||
import type { AutomationResultDTO } from '../dto/AutomationResultDTO';
|
||||
import type { FormFillResultDTO } from '../dto/FormFillResultDTO';
|
||||
|
||||
/**
|
||||
* Browser automation interface for Playwright-based automation.
|
||||
*
|
||||
* This interface defines the contract for browser automation using
|
||||
* standard DOM manipulation via Playwright. All automation is done
|
||||
* through browser DevTools protocol - no OS-level automation.
|
||||
*/
|
||||
export interface IBrowserAutomation {
|
||||
/**
|
||||
* Navigate to a URL.
|
||||
*/
|
||||
navigateToPage(url: string): Promise<NavigationResultDTO>;
|
||||
|
||||
/**
|
||||
* Fill a form field by name or selector.
|
||||
*/
|
||||
fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO>;
|
||||
|
||||
/**
|
||||
* Click an element by selector or action name.
|
||||
*/
|
||||
clickElement(target: string): Promise<ClickResultDTO>;
|
||||
|
||||
/**
|
||||
* Wait for an element to appear.
|
||||
*/
|
||||
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO>;
|
||||
|
||||
/**
|
||||
* Handle modal dialogs.
|
||||
*/
|
||||
handleModal(stepId: StepId, action: string): Promise<ModalResultDTO>;
|
||||
|
||||
/**
|
||||
* Execute a complete workflow step.
|
||||
*/
|
||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO>;
|
||||
|
||||
/**
|
||||
* Initialize the browser connection.
|
||||
* Returns an AutomationResult indicating success or failure.
|
||||
*/
|
||||
connect?(): Promise<AutomationResultDTO>;
|
||||
|
||||
/**
|
||||
* Clean up browser resources.
|
||||
*/
|
||||
disconnect?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if browser is connected and ready.
|
||||
*/
|
||||
isConnected?(): boolean;
|
||||
}
|
||||
11
core/automation/application/ports/SessionRepositoryPort.ts
Normal file
11
core/automation/application/ports/SessionRepositoryPort.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import { SessionStateValue } from '../../domain/value-objects/SessionState';
|
||||
|
||||
export interface SessionRepositoryPort {
|
||||
save(session: AutomationSession): Promise<void>;
|
||||
findById(id: string): Promise<AutomationSession | null>;
|
||||
update(session: AutomationSession): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
findAll(): Promise<AutomationSession[]>;
|
||||
findByState(state: SessionStateValue): Promise<AutomationSession[]>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Result } from '../../../shared/result/Result';
|
||||
|
||||
export interface SessionValidatorPort {
|
||||
validateSession(): Promise<Result<boolean>>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IUserConfirmationPort {
|
||||
confirm(message: string): Promise<boolean>;
|
||||
}
|
||||
131
core/automation/application/services/OverlaySyncService.ts
Normal file
131
core/automation/application/services/OverlaySyncService.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncPort';
|
||||
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
||||
import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||
import { LoggerPort } from '../ports/LoggerPort';
|
||||
import type { IAsyncApplicationService } from '@gridpilot/shared/application';
|
||||
|
||||
type ConstructorArgs = {
|
||||
lifecycleEmitter: IAutomationLifecycleEmitter
|
||||
publisher: AutomationEventPublisherPort
|
||||
logger: LoggerPort
|
||||
initialPanelWaitMs?: number
|
||||
maxPanelRetries?: number
|
||||
backoffFactor?: number
|
||||
defaultTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class OverlaySyncService
|
||||
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
|
||||
{
|
||||
private lifecycleEmitter: IAutomationLifecycleEmitter
|
||||
private publisher: AutomationEventPublisherPort
|
||||
private logger: LoggerPort
|
||||
private initialPanelWaitMs: number
|
||||
private maxPanelRetries: number
|
||||
private backoffFactor: number
|
||||
private defaultTimeoutMs: number
|
||||
|
||||
constructor(args: ConstructorArgs) {
|
||||
this.lifecycleEmitter = args.lifecycleEmitter
|
||||
this.publisher = args.publisher
|
||||
this.logger = args.logger
|
||||
this.initialPanelWaitMs = args.initialPanelWaitMs ?? 500
|
||||
this.maxPanelRetries = args.maxPanelRetries ?? 3
|
||||
this.backoffFactor = args.backoffFactor ?? 2
|
||||
this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000
|
||||
}
|
||||
|
||||
async execute(action: OverlayAction): Promise<ActionAck> {
|
||||
return this.startAction(action)
|
||||
}
|
||||
|
||||
async startAction(action: OverlayAction): Promise<ActionAck> {
|
||||
const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs
|
||||
const seenEvents: AutomationEvent[] = []
|
||||
let settled = false
|
||||
|
||||
const cb: LifecycleCallback = async (ev) => {
|
||||
seenEvents.push(ev)
|
||||
if (ev.type === 'action-started' && ev.actionId === action.id) {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveAck({ id: action.id, status: 'confirmed' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
this.lifecycleEmitter.offLifecycle(cb)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let resolveAck: (ack: ActionAck) => void = () => {}
|
||||
const promise = new Promise<ActionAck>((resolve) => {
|
||||
resolveAck = resolve
|
||||
try {
|
||||
this.lifecycleEmitter.onLifecycle(cb)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, {
|
||||
actionId: action.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
void this.publisher.publish({
|
||||
type: 'modal-opened',
|
||||
timestamp: Date.now(),
|
||||
payload: { actionId: action.id, label: action.label },
|
||||
actionId: action.id,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: publisher.publish failed', {
|
||||
actionId: action.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise<ActionAck>((res) => {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', {
|
||||
actionId: action.id,
|
||||
timeoutMs,
|
||||
})
|
||||
const lastEvents = seenEvents.slice(-10)
|
||||
this.logger?.debug?.('OverlaySyncService: recent lifecycle events', {
|
||||
actionId: action.id,
|
||||
events: lastEvents,
|
||||
})
|
||||
res({ id: action.id, status: 'tentative', reason: 'timeout' })
|
||||
}
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise])
|
||||
}
|
||||
|
||||
async cancelAction(actionId: string): Promise<void> {
|
||||
try {
|
||||
await this.publisher.publish({
|
||||
type: 'panel-missing',
|
||||
timestamp: Date.now(),
|
||||
actionId,
|
||||
} as AutomationEvent)
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e))
|
||||
this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', {
|
||||
actionId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import { SessionLifetime } from '../../domain/value-objects/SessionLifetime';
|
||||
import type { SessionValidatorPort } from '../ports/SessionValidatorPort';
|
||||
|
||||
/**
|
||||
* Use case for checking if the user has a valid iRacing session.
|
||||
*
|
||||
* This validates the session before automation starts, allowing
|
||||
* the system to prompt for re-authentication if needed.
|
||||
*
|
||||
* Implements hybrid validation strategy:
|
||||
* - File-based validation (fast, always executed)
|
||||
* - Optional server-side validation (slow, requires network)
|
||||
*/
|
||||
export class CheckAuthenticationUseCase {
|
||||
constructor(
|
||||
private readonly logger: ILogger,
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly sessionValidator?: SessionValidatorPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the authentication check.
|
||||
*
|
||||
* @param options Optional configuration for validation
|
||||
* @returns Result containing the current AuthenticationState
|
||||
*/
|
||||
async execute(options?: {
|
||||
requireServerValidation?: boolean;
|
||||
verifyPageContent?: boolean;
|
||||
}): Promise<Result<AuthenticationState>> {
|
||||
this.logger.debug('Executing CheckAuthenticationUseCase', { options });
|
||||
try {
|
||||
// Step 1: File-based validation (fast)
|
||||
this.logger.debug('Performing file-based authentication check.');
|
||||
const fileResult = await this.authService.checkSession();
|
||||
if (fileResult.isErr()) {
|
||||
this.logger.error('File-based authentication check failed.', { error: fileResult.unwrapErr() });
|
||||
return fileResult;
|
||||
}
|
||||
this.logger.info('File-based authentication check succeeded.');
|
||||
|
||||
const fileState = fileResult.unwrap();
|
||||
this.logger.debug(`File-based authentication state: ${fileState}`);
|
||||
|
||||
// Step 2: Check session expiry if authenticated
|
||||
if (fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Session is authenticated, checking expiry.');
|
||||
const expiryResult = await this.authService.getSessionExpiry();
|
||||
if (expiryResult.isErr()) {
|
||||
this.logger.warn('Could not retrieve session expiry, proceeding with file-based state.', { error: expiryResult.unwrapErr() });
|
||||
// Don't fail completely if we can't get expiry, use file-based state
|
||||
return Result.ok(fileState);
|
||||
}
|
||||
|
||||
const expiry = expiryResult.unwrap();
|
||||
if (expiry !== null) {
|
||||
try {
|
||||
const sessionLifetime = new SessionLifetime(expiry);
|
||||
if (sessionLifetime.isExpired()) {
|
||||
this.logger.info('Session has expired based on lifetime.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.debug('Session is not expired.');
|
||||
} catch (error) {
|
||||
this.logger.error('Invalid expiry date encountered, treating session as expired.', { expiry, error });
|
||||
// Invalid expiry date, treat as expired for safety
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Optional page content verification
|
||||
if (options?.verifyPageContent && fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Performing optional page content verification.');
|
||||
const pageResult = await this.authService.verifyPageAuthentication();
|
||||
|
||||
if (pageResult.isOk()) {
|
||||
const browserState = pageResult.unwrap();
|
||||
// If cookies valid but page shows login UI, session is expired
|
||||
if (!browserState.isFullyAuthenticated()) {
|
||||
this.logger.info('Page content verification indicated session expired.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.info('Page content verification succeeded.');
|
||||
} else {
|
||||
this.logger.warn('Page content verification failed, proceeding with file-based state.', { error: pageResult.unwrapErr() });
|
||||
}
|
||||
// Don't block on page verification errors, continue with file-based state
|
||||
}
|
||||
|
||||
// Step 4: Optional server-side validation
|
||||
if (this.sessionValidator && fileState === AuthenticationState.AUTHENTICATED) {
|
||||
this.logger.debug('Performing optional server-side validation.');
|
||||
const serverResult = await this.sessionValidator.validateSession();
|
||||
|
||||
// Don't block on server validation errors
|
||||
if (serverResult.isOk()) {
|
||||
const isValid = serverResult.unwrap();
|
||||
if (!isValid) {
|
||||
this.logger.info('Server-side validation indicated session expired.');
|
||||
return Result.ok(AuthenticationState.EXPIRED);
|
||||
}
|
||||
this.logger.info('Server-side validation succeeded.');
|
||||
} else {
|
||||
this.logger.warn('Server-side validation failed, proceeding with file-based state.', { error: serverResult.unwrapErr() });
|
||||
}
|
||||
}
|
||||
this.logger.info(`CheckAuthenticationUseCase completed successfully with state: ${fileState}`);
|
||||
return Result.ok(fileState);
|
||||
} catch (error) {
|
||||
this.logger.error('An unexpected error occurred during authentication check.', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
core/automation/application/use-cases/ClearSessionUseCase.ts
Normal file
47
core/automation/application/use-cases/ClearSessionUseCase.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
/**
|
||||
* Use case for clearing the user's session (logout).
|
||||
*
|
||||
* Removes stored browser context and cookies, effectively logging
|
||||
* the user out. The next automation attempt will require re-authentication.
|
||||
*/
|
||||
export class ClearSessionUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: ILogger, // Inject ILogger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the session clearing.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
this.logger.debug('Attempting to clear user session.', {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
try {
|
||||
const result = await this.authService.clearSession();
|
||||
|
||||
if (result.isSuccess) {
|
||||
this.logger.info('User session cleared successfully.', {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('Failed to clear user session.', {
|
||||
useCase: 'ClearSessionUseCase',
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error clearing user session.', error, {
|
||||
useCase: 'ClearSessionUseCase'
|
||||
});
|
||||
return Result.fail(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult';
|
||||
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
export class CompleteRaceCreationUseCase {
|
||||
constructor(private readonly checkoutService: CheckoutServicePort, private readonly logger: ILogger) {}
|
||||
|
||||
async execute(sessionId: string): Promise<Result<RaceCreationResult>> {
|
||||
this.logger.debug(`Attempting to complete race creation for session ID: ${sessionId}`);
|
||||
if (!sessionId || sessionId.trim() === '') {
|
||||
this.logger.error('Session ID is required for completing race creation.');
|
||||
return Result.err(new Error('Session ID is required'));
|
||||
}
|
||||
|
||||
const infoResult = await this.checkoutService.extractCheckoutInfo();
|
||||
|
||||
if (infoResult.isErr()) {
|
||||
this.logger.error(`Failed to extract checkout info: ${infoResult.unwrapErr().message}`);
|
||||
return Result.err(infoResult.unwrapErr());
|
||||
}
|
||||
|
||||
const info = infoResult.unwrap();
|
||||
this.logger.debug(`Extracted checkout information: ${JSON.stringify(info)}`);
|
||||
|
||||
if (!info.price) {
|
||||
this.logger.error('Could not extract price from checkout page.');
|
||||
return Result.err(new Error('Could not extract price from checkout page'));
|
||||
}
|
||||
|
||||
try {
|
||||
const raceCreationResult = RaceCreationResult.create({
|
||||
sessionId,
|
||||
price: info.price.toDisplayString(),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
this.logger.info(`Race creation completed successfully for session ID: ${sessionId}`);
|
||||
return Result.ok(raceCreationResult);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
this.logger.error(`Error completing race creation for session ID ${sessionId}: ${err.message}`);
|
||||
return Result.err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
|
||||
import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort';
|
||||
import { CheckoutStateEnum } from '../../domain/value-objects/CheckoutState';
|
||||
|
||||
interface SessionMetadata {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
}
|
||||
|
||||
export class ConfirmCheckoutUseCase {
|
||||
private static readonly DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
constructor(
|
||||
private readonly checkoutService: CheckoutServicePort,
|
||||
private readonly confirmationPort: CheckoutConfirmationPort,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(sessionMetadata?: SessionMetadata): Promise<Result<void>> {
|
||||
this.logger.debug('Executing ConfirmCheckoutUseCase', { sessionMetadata });
|
||||
|
||||
const infoResult = await this.checkoutService.extractCheckoutInfo();
|
||||
|
||||
if (infoResult.isErr()) {
|
||||
this.logger.error('Failed to extract checkout info', { error: infoResult.unwrapErr() });
|
||||
return Result.err(infoResult.unwrapErr());
|
||||
}
|
||||
|
||||
const info = infoResult.unwrap();
|
||||
this.logger.info('Extracted checkout info', { state: info.state.getValue(), price: info.price });
|
||||
|
||||
|
||||
if (info.state.getValue() === CheckoutStateEnum.INSUFFICIENT_FUNDS) {
|
||||
this.logger.error('Insufficient funds to complete checkout');
|
||||
return Result.err(new Error('Insufficient funds to complete checkout'));
|
||||
}
|
||||
|
||||
if (!info.price) {
|
||||
this.logger.error('Could not extract price from checkout page');
|
||||
return Result.err(new Error('Could not extract price from checkout page'));
|
||||
}
|
||||
|
||||
this.logger.debug('Requesting checkout confirmation', { price: info.price, state: info.state.getValue(), sessionMetadata });
|
||||
|
||||
// Request confirmation via port with full checkout context
|
||||
const confirmationResult = await this.confirmationPort.requestCheckoutConfirmation({
|
||||
price: info.price,
|
||||
state: info.state,
|
||||
sessionMetadata: sessionMetadata || {
|
||||
sessionName: 'Unknown Session',
|
||||
trackId: 'unknown',
|
||||
carIds: [],
|
||||
},
|
||||
timeoutMs: ConfirmCheckoutUseCase.DEFAULT_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (confirmationResult.isErr()) {
|
||||
this.logger.error('Checkout confirmation failed', { error: confirmationResult.unwrapErr() });
|
||||
return Result.err(confirmationResult.unwrapErr());
|
||||
}
|
||||
|
||||
const confirmation = confirmationResult.unwrap();
|
||||
this.logger.info('Checkout confirmation received', { confirmation });
|
||||
|
||||
if (confirmation.isCancelled()) {
|
||||
this.logger.error('Checkout cancelled by user');
|
||||
return Result.err(new Error('Checkout cancelled by user'));
|
||||
}
|
||||
|
||||
if (confirmation.isTimeout()) {
|
||||
this.logger.error('Checkout confirmation timeout');
|
||||
return Result.err(new Error('Checkout confirmation timeout'));
|
||||
}
|
||||
|
||||
this.logger.info('Proceeding with checkout');
|
||||
const checkoutResult = await this.checkoutService.proceedWithCheckout();
|
||||
|
||||
if (checkoutResult.isOk()) {
|
||||
this.logger.info('Checkout process completed successfully.');
|
||||
} else {
|
||||
this.logger.error('Checkout process failed', { error: checkoutResult.unwrapErr() });
|
||||
}
|
||||
return checkoutResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import type { ILogger } from '../../../shared/logger/ILogger';
|
||||
|
||||
/**
|
||||
* Use case for initiating the manual login flow.
|
||||
*
|
||||
* Opens a visible browser window where the user can log into iRacing directly.
|
||||
* GridPilot never sees the credentials - it only waits for the URL to change
|
||||
* indicating successful login.
|
||||
*/
|
||||
export class InitiateLoginUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the login flow.
|
||||
* Opens browser and waits for user to complete manual login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
this.logger.debug('Initiating login flow...');
|
||||
try {
|
||||
const result = await this.authService.initiateLogin();
|
||||
if (result.isOk()) {
|
||||
this.logger.info('Login flow initiated successfully.');
|
||||
} else {
|
||||
this.logger.warn('Login flow initiation failed.', { error: result.error });
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error initiating login flow.', error);
|
||||
return Result.fail(error.message || 'Unknown error during login initiation.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import { AutomationSession } from '../../domain/entities/AutomationSession';
|
||||
import type { HostedSessionConfig } from '../../domain/types/HostedSessionConfig';
|
||||
import { AutomationEnginePort } from '../ports/AutomationEnginePort';
|
||||
import type { IBrowserAutomation } from '../ports/ScreenAutomationPort';
|
||||
import { SessionRepositoryPort } from '../ports/SessionRepositoryPort';
|
||||
import type { SessionDTO } from '../dto/SessionDTO';
|
||||
|
||||
export class StartAutomationSessionUseCase
|
||||
implements AsyncUseCase<HostedSessionConfig, SessionDTO> {
|
||||
constructor(
|
||||
private readonly automationEngine: AutomationEnginePort,
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: SessionRepositoryPort,
|
||||
private readonly logger: ILogger
|
||||
) {}
|
||||
|
||||
async execute(config: HostedSessionConfig): Promise<SessionDTO> {
|
||||
this.logger.debug('Starting automation session execution', { config });
|
||||
|
||||
const session = AutomationSession.create(config);
|
||||
this.logger.info(`Automation session created with ID: ${session.id}`);
|
||||
|
||||
const validationResult = await this.automationEngine.validateConfiguration(config);
|
||||
if (!validationResult.isValid) {
|
||||
this.logger.warn('Automation session configuration validation failed', { config, error: validationResult.error });
|
||||
this.logger.error('Automation session configuration validation failed', { config, error: validationResult.error });
|
||||
throw new Error(validationResult.error);
|
||||
}
|
||||
this.logger.debug('Automation session configuration validated successfully.');
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
this.logger.info(`Automation session with ID: ${session.id} saved to repository.`);
|
||||
|
||||
const dto: SessionDTO = {
|
||||
sessionId: session.id,
|
||||
state: session.state.value,
|
||||
currentStep: session.currentStep.value,
|
||||
config: session.config,
|
||||
...(session.startedAt ? { startedAt: session.startedAt } : {}),
|
||||
...(session.completedAt ? { completedAt: session.completedAt } : {}),
|
||||
...(session.errorMessage ? { errorMessage: session.errorMessage } : {}),
|
||||
};
|
||||
|
||||
this.logger.debug('Automation session executed successfully, returning DTO.', { dto });
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
/**
|
||||
* Use case for verifying browser shows authenticated page state.
|
||||
* Combines cookie validation with page content verification.
|
||||
*/
|
||||
export class VerifyAuthenticatedPageUseCase {
|
||||
constructor(
|
||||
private readonly authService: AuthenticationServicePort,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<BrowserAuthenticationState>> {
|
||||
this.logger.debug('Executing VerifyAuthenticatedPageUseCase');
|
||||
try {
|
||||
const result = await this.authService.verifyPageAuthentication();
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.error ?? new Error('Page verification failed');
|
||||
this.logger.error(`Page verification failed: ${error.message}`, error);
|
||||
return Result.err<BrowserAuthenticationState>(error);
|
||||
}
|
||||
|
||||
const browserState = result.unwrap();
|
||||
this.logger.info('Successfully verified authenticated page state.');
|
||||
return Result.ok<BrowserAuthenticationState>(browserState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Page verification failed unexpectedly: ${message}`, error);
|
||||
return Result.err<BrowserAuthenticationState>(new Error(`Page verification failed: ${message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
core/automation/domain/entities/AutomationSession.ts
Normal file
145
core/automation/domain/entities/AutomationSession.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
import type { HostedSessionConfig } from '../types/HostedSessionConfig';
|
||||
import { AutomationDomainError } from '../errors/AutomationDomainError';
|
||||
|
||||
export class AutomationSession implements IEntity<string> {
|
||||
private readonly _id: string;
|
||||
private _currentStep: StepId;
|
||||
private _state: SessionState;
|
||||
private readonly _config: HostedSessionConfig;
|
||||
private _startedAt?: Date;
|
||||
private _completedAt?: Date;
|
||||
private _errorMessage?: string;
|
||||
|
||||
private constructor(
|
||||
id: string,
|
||||
currentStep: StepId,
|
||||
state: SessionState,
|
||||
config: HostedSessionConfig
|
||||
) {
|
||||
this._id = id;
|
||||
this._currentStep = currentStep;
|
||||
this._state = state;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
static create(config: HostedSessionConfig): AutomationSession {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
throw new AutomationDomainError('Session name cannot be empty');
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
throw new AutomationDomainError('Track ID is required');
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
throw new AutomationDomainError('At least one car must be selected');
|
||||
}
|
||||
|
||||
return new AutomationSession(
|
||||
randomUUID(),
|
||||
StepId.create(1),
|
||||
SessionState.create('PENDING'),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get currentStep(): StepId {
|
||||
return this._currentStep;
|
||||
}
|
||||
|
||||
get state(): SessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get config(): HostedSessionConfig {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
get startedAt(): Date | undefined {
|
||||
return this._startedAt;
|
||||
}
|
||||
|
||||
get completedAt(): Date | undefined {
|
||||
return this._completedAt;
|
||||
}
|
||||
|
||||
get errorMessage(): string | undefined {
|
||||
return this._errorMessage;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this._state.isPending()) {
|
||||
throw new AutomationDomainError('Cannot start session that is not pending');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
this._startedAt = new Date();
|
||||
}
|
||||
|
||||
transitionToStep(targetStep: StepId): void {
|
||||
if (!this._state.isInProgress()) {
|
||||
throw new AutomationDomainError('Cannot transition steps when session is not in progress');
|
||||
}
|
||||
|
||||
if (this._currentStep.equals(targetStep)) {
|
||||
throw new AutomationDomainError('Already at this step');
|
||||
}
|
||||
|
||||
if (targetStep.value < this._currentStep.value) {
|
||||
throw new AutomationDomainError('Cannot move backward - steps must progress forward only');
|
||||
}
|
||||
|
||||
if (targetStep.value !== this._currentStep.value + 1) {
|
||||
throw new AutomationDomainError('Cannot skip steps - must transition sequentially');
|
||||
}
|
||||
|
||||
this._currentStep = targetStep;
|
||||
|
||||
if (this._currentStep.isFinalStep()) {
|
||||
this._state = SessionState.create('STOPPED_AT_STEP_18');
|
||||
this._completedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this._state.isInProgress()) {
|
||||
throw new AutomationDomainError('Cannot pause session that is not in progress');
|
||||
}
|
||||
this._state = SessionState.create('PAUSED');
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this._state.value !== 'PAUSED') {
|
||||
throw new AutomationDomainError('Cannot resume session that is not paused');
|
||||
}
|
||||
this._state = SessionState.create('IN_PROGRESS');
|
||||
}
|
||||
|
||||
fail(errorMessage: string): void {
|
||||
if (this._state.isTerminal()) {
|
||||
throw new AutomationDomainError('Cannot fail terminal session');
|
||||
}
|
||||
this._state = SessionState.create('FAILED');
|
||||
this._errorMessage = errorMessage;
|
||||
this._completedAt = new Date();
|
||||
}
|
||||
|
||||
isAtModalStep(): boolean {
|
||||
return this._currentStep.isModalStep();
|
||||
}
|
||||
|
||||
getElapsedTime(): number {
|
||||
if (!this._startedAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const endTime = this._completedAt || new Date();
|
||||
const elapsed = endTime.getTime() - this._startedAt.getTime();
|
||||
return elapsed > 0 ? elapsed : 1;
|
||||
}
|
||||
}
|
||||
15
core/automation/domain/entities/StepExecution.ts
Normal file
15
core/automation/domain/entities/StepExecution.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { StepId } from '../value-objects/StepId';
|
||||
|
||||
/**
|
||||
* Domain Type: StepExecution
|
||||
*
|
||||
* Represents execution metadata for a single automation step.
|
||||
* This is a pure data shape (DTO-like), not an entity or value object.
|
||||
*/
|
||||
export interface StepExecution {
|
||||
stepId: StepId;
|
||||
startedAt: Date;
|
||||
completedAt?: Date;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
19
core/automation/domain/errors/AutomationDomainError.ts
Normal file
19
core/automation/domain/errors/AutomationDomainError.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IDomainError } from '@gridpilot/shared/errors';
|
||||
|
||||
/**
|
||||
* Domain Error: AutomationDomainError
|
||||
*
|
||||
* Implements the shared IDomainError contract for automation domain failures.
|
||||
*/
|
||||
export class AutomationDomainError extends Error implements IDomainError {
|
||||
readonly name = 'AutomationDomainError';
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'automation';
|
||||
readonly kind: string;
|
||||
|
||||
constructor(message: string, kind: string = 'validation') {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
242
core/automation/domain/services/PageStateValidator.ts
Normal file
242
core/automation/domain/services/PageStateValidator.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { IDomainValidationService } from '@gridpilot/shared/domain';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
/**
|
||||
* Configuration for page state validation.
|
||||
* Defines expected and forbidden elements on the current page.
|
||||
*/
|
||||
export interface PageStateValidation {
|
||||
/** Expected wizard step name (e.g., 'cars', 'track') */
|
||||
expectedStep: string;
|
||||
/** Selectors that MUST be present on the page */
|
||||
requiredSelectors: string[];
|
||||
/** Selectors that MUST NOT be present on the page */
|
||||
forbiddenSelectors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of page state validation.
|
||||
*/
|
||||
export interface PageStateValidationResult {
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
expectedStep: string;
|
||||
missingSelectors?: string[];
|
||||
unexpectedSelectors?: string[];
|
||||
}
|
||||
|
||||
export interface PageStateValidationInput {
|
||||
actualState: (selector: string) => boolean;
|
||||
validation: PageStateValidation;
|
||||
realMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain service for validating page state during wizard navigation.
|
||||
*
|
||||
* Purpose: Prevent navigation bugs by ensuring each step executes on the correct page.
|
||||
*
|
||||
* Clean Architecture: This is pure domain logic with no infrastructure dependencies.
|
||||
* It validates state based on selector presence/absence without knowing HOW to check them.
|
||||
*/
|
||||
export class PageStateValidator
|
||||
implements
|
||||
IDomainValidationService<PageStateValidationInput, PageStateValidationResult, Error>
|
||||
{
|
||||
validate(input: PageStateValidationInput): Result<PageStateValidationResult, Error> {
|
||||
const { actualState, validation, realMode } = input;
|
||||
if (typeof realMode === 'boolean') {
|
||||
return this.validateStateEnhanced(actualState, validation, realMode);
|
||||
}
|
||||
return this.validateState(actualState, validation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the page state matches expected conditions.
|
||||
*
|
||||
* @param actualState Function that checks if selectors exist on the page
|
||||
* @param validation Expected page state configuration
|
||||
* @returns Result with validation outcome
|
||||
*/
|
||||
validateState(
|
||||
actualState: (selector: string) => boolean,
|
||||
validation: PageStateValidation
|
||||
): Result<PageStateValidationResult, Error> {
|
||||
try {
|
||||
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
|
||||
|
||||
// Check required selectors are present
|
||||
const missingSelectors = requiredSelectors.filter(selector => !actualState(selector));
|
||||
|
||||
if (missingSelectors.length > 0) {
|
||||
const result: PageStateValidationResult = {
|
||||
isValid: false,
|
||||
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
|
||||
expectedStep,
|
||||
missingSelectors
|
||||
};
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
// Check forbidden selectors are absent
|
||||
const unexpectedSelectors = forbiddenSelectors.filter(selector => actualState(selector));
|
||||
|
||||
if (unexpectedSelectors.length > 0) {
|
||||
const result: PageStateValidationResult = {
|
||||
isValid: false,
|
||||
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
|
||||
expectedStep,
|
||||
unexpectedSelectors
|
||||
};
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
const result: PageStateValidationResult = {
|
||||
isValid: true,
|
||||
message: `Page state valid for "${expectedStep}"`,
|
||||
expectedStep
|
||||
};
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Page state validation failed: ${String(error)}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced validation that tries multiple selector strategies for real iRacing HTML.
|
||||
* This handles the mismatch between test expectations (data-indicator attributes)
|
||||
* and real HTML structure (Chakra UI components).
|
||||
*
|
||||
* @param actualState Function that checks if selectors exist on the page
|
||||
* @param validation Expected page state configuration
|
||||
* @param realMode Whether we're in real mode (using real HTML dumps) or mock mode
|
||||
* @returns Result with validation outcome
|
||||
*/
|
||||
validateStateEnhanced(
|
||||
actualState: (selector: string) => boolean,
|
||||
validation: PageStateValidation,
|
||||
realMode: boolean = false
|
||||
): Result<PageStateValidationResult, Error> {
|
||||
try {
|
||||
const { expectedStep, requiredSelectors, forbiddenSelectors = [] } = validation;
|
||||
|
||||
// In real mode, try to match the actual HTML structure with fallbacks
|
||||
let selectorsToCheck = [...requiredSelectors];
|
||||
|
||||
if (realMode) {
|
||||
// Add fallback selectors for real iRacing HTML (Chakra UI structure)
|
||||
const fallbackMap: Record<string, string[]> = {
|
||||
cars: [
|
||||
'#set-cars',
|
||||
'[id*="cars"]',
|
||||
'.wizard-step[id*="cars"]',
|
||||
'.cars-panel',
|
||||
// Real iRacing fallbacks - use step container IDs
|
||||
'[data-testid*="set-cars"]',
|
||||
'.chakra-stack:has([data-testid*="cars"])',
|
||||
],
|
||||
track: [
|
||||
'#set-track',
|
||||
'[id*="track"]',
|
||||
'.wizard-step[id*="track"]',
|
||||
'.track-panel',
|
||||
// Real iRacing fallbacks
|
||||
'[data-testid*="set-track"]',
|
||||
'.chakra-stack:has([data-testid*="track"])',
|
||||
],
|
||||
'add-car': [
|
||||
'a.btn:has-text("Add a Car")',
|
||||
'.btn:has-text("Add a Car")',
|
||||
'[data-testid*="add-car"]',
|
||||
// Real iRacing button selectors
|
||||
'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Car")',
|
||||
],
|
||||
};
|
||||
|
||||
// For each required selector, add fallbacks
|
||||
const enhancedSelectors: string[] = [];
|
||||
for (const selector of requiredSelectors) {
|
||||
enhancedSelectors.push(selector);
|
||||
|
||||
// Add step-specific fallbacks
|
||||
const lowerStep = expectedStep.toLowerCase();
|
||||
if (fallbackMap[lowerStep]) {
|
||||
enhancedSelectors.push(...fallbackMap[lowerStep]);
|
||||
}
|
||||
|
||||
// Generic Chakra UI fallbacks for wizard steps
|
||||
if (selector.includes('data-indicator')) {
|
||||
enhancedSelectors.push(
|
||||
`[id*="${expectedStep}"]`,
|
||||
`[data-testid*="${expectedStep}"]`,
|
||||
`.wizard-step:has([data-testid*="${expectedStep}"])`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selectorsToCheck = enhancedSelectors;
|
||||
}
|
||||
|
||||
// Check required selectors are present (with fallbacks for real mode)
|
||||
const missingSelectors = requiredSelectors.filter(selector => {
|
||||
if (realMode) {
|
||||
const relatedSelectors = selectorsToCheck.filter(s =>
|
||||
s.includes(expectedStep) ||
|
||||
s.includes(
|
||||
selector
|
||||
.replace(/[\[\]"']/g, '')
|
||||
.replace('data-indicator=', ''),
|
||||
),
|
||||
);
|
||||
if (relatedSelectors.length === 0) {
|
||||
return !actualState(selector);
|
||||
}
|
||||
return !relatedSelectors.some(s => actualState(s));
|
||||
}
|
||||
return !actualState(selector);
|
||||
});
|
||||
|
||||
if (missingSelectors.length > 0) {
|
||||
const result: PageStateValidationResult = {
|
||||
isValid: false,
|
||||
message: `Page state mismatch: Expected to be on "${expectedStep}" page but missing required elements`,
|
||||
expectedStep,
|
||||
missingSelectors
|
||||
};
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
// Check forbidden selectors are absent
|
||||
const unexpectedSelectors = forbiddenSelectors.filter(selector => actualState(selector));
|
||||
|
||||
if (unexpectedSelectors.length > 0) {
|
||||
const result: PageStateValidationResult = {
|
||||
isValid: false,
|
||||
message: `Page state mismatch: Found unexpected elements on "${expectedStep}" page`,
|
||||
expectedStep,
|
||||
unexpectedSelectors
|
||||
};
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
const result: PageStateValidationResult = {
|
||||
isValid: true,
|
||||
message: `Page state valid for "${expectedStep}"`,
|
||||
expectedStep
|
||||
};
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Page state validation failed: ${String(error)}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
core/automation/domain/services/StepTransitionValidator.ts
Normal file
106
core/automation/domain/services/StepTransitionValidator.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { StepId } from '../value-objects/StepId';
|
||||
import { SessionState } from '../value-objects/SessionState';
|
||||
import type { IDomainValidationService } from '@gridpilot/shared/domain';
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StepTransitionValidationInput {
|
||||
currentStep: StepId;
|
||||
nextStep: StepId;
|
||||
state: SessionState;
|
||||
}
|
||||
|
||||
export interface StepTransitionValidationResult extends ValidationResult {}
|
||||
|
||||
const STEP_DESCRIPTIONS: Record<number, string> = {
|
||||
1: 'Navigate to Hosted Racing page',
|
||||
2: 'Click Create a Race',
|
||||
3: 'Fill Race Information',
|
||||
4: 'Configure Server Details',
|
||||
5: 'Set Admins',
|
||||
6: 'Add Admin (Modal)',
|
||||
7: 'Set Time Limits',
|
||||
8: 'Set Cars',
|
||||
9: 'Add a Car (Modal)',
|
||||
10: 'Set Car Classes',
|
||||
11: 'Set Track',
|
||||
12: 'Add a Track (Modal)',
|
||||
13: 'Configure Track Options',
|
||||
14: 'Set Time of Day',
|
||||
15: 'Configure Weather',
|
||||
16: 'Set Race Options',
|
||||
17: 'Track Conditions (STOP - Manual Submit Required)',
|
||||
};
|
||||
|
||||
export class StepTransitionValidator
|
||||
implements
|
||||
IDomainValidationService<StepTransitionValidationInput, StepTransitionValidationResult, Error>
|
||||
{
|
||||
validate(input: StepTransitionValidationInput): Result<StepTransitionValidationResult, Error> {
|
||||
try {
|
||||
const { currentStep, nextStep, state } = input;
|
||||
const result = StepTransitionValidator.canTransition(currentStep, nextStep, state);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Step transition validation failed: ${String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
static canTransition(
|
||||
currentStep: StepId,
|
||||
nextStep: StepId,
|
||||
state: SessionState
|
||||
): ValidationResult {
|
||||
if (!state.isInProgress()) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Session must be in progress to transition steps',
|
||||
};
|
||||
}
|
||||
|
||||
if (currentStep.equals(nextStep)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Already at this step',
|
||||
};
|
||||
}
|
||||
|
||||
if (nextStep.value < currentStep.value) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Cannot move backward - steps must progress forward only',
|
||||
};
|
||||
}
|
||||
|
||||
if (nextStep.value !== currentStep.value + 1) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Cannot skip steps - must progress sequentially',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
static validateModalStepTransition(
|
||||
currentStep: StepId,
|
||||
nextStep: StepId
|
||||
): ValidationResult {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
static shouldStopAtStep18(nextStep: StepId): boolean {
|
||||
return nextStep.isFinalStep();
|
||||
}
|
||||
|
||||
static getStepDescription(step: StepId): string {
|
||||
return STEP_DESCRIPTIONS[step.value] || `Step ${step.value}`;
|
||||
}
|
||||
}
|
||||
78
core/automation/domain/shared/Result.ts
Normal file
78
core/automation/domain/shared/Result.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export class Result<T, E = Error> {
|
||||
private constructor(
|
||||
private readonly _value?: T,
|
||||
private readonly _error?: E,
|
||||
private readonly _isSuccess: boolean = true
|
||||
) {}
|
||||
|
||||
static ok<T, E = Error>(value: T): Result<T, E> {
|
||||
return new Result<T, E>(value, undefined, true);
|
||||
}
|
||||
|
||||
static err<T, E = Error>(error: E): Result<T, E> {
|
||||
return new Result<T, E>(undefined, error, false);
|
||||
}
|
||||
|
||||
isOk(): boolean {
|
||||
return this._isSuccess;
|
||||
}
|
||||
|
||||
isErr(): boolean {
|
||||
return !this._isSuccess;
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
if (!this._isSuccess) {
|
||||
throw new Error('Called unwrap on an error result');
|
||||
}
|
||||
return this._value!;
|
||||
}
|
||||
|
||||
unwrapOr(defaultValue: T): T {
|
||||
return this._isSuccess ? this._value! : defaultValue;
|
||||
}
|
||||
|
||||
unwrapErr(): E {
|
||||
if (this._isSuccess) {
|
||||
throw new Error('Called unwrapErr on a success result');
|
||||
}
|
||||
return this._error!;
|
||||
}
|
||||
|
||||
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||
if (this._isSuccess) {
|
||||
return Result.ok(fn(this._value!));
|
||||
}
|
||||
return Result.err(this._error!);
|
||||
}
|
||||
|
||||
mapErr<F>(fn: (error: E) => F): Result<T, F> {
|
||||
if (!this._isSuccess) {
|
||||
return Result.err(fn(this._error!));
|
||||
}
|
||||
return Result.ok(this._value!);
|
||||
}
|
||||
|
||||
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
|
||||
if (this._isSuccess) {
|
||||
return fn(this._value!);
|
||||
}
|
||||
return Result.err(this._error!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct access to the value (for testing convenience).
|
||||
* Prefer using unwrap() in production code.
|
||||
*/
|
||||
get value(): T | undefined {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct access to the error (for testing convenience).
|
||||
* Prefer using unwrapErr() in production code.
|
||||
*/
|
||||
get error(): E | undefined {
|
||||
return this._error;
|
||||
}
|
||||
}
|
||||
34
core/automation/domain/types/HostedSessionConfig.ts
Normal file
34
core/automation/domain/types/HostedSessionConfig.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Domain Type: HostedSessionConfig
|
||||
*
|
||||
* Pure configuration shape for an iRacing hosted session.
|
||||
* This is a DTO-like domain type, not a value object or entity.
|
||||
*/
|
||||
export interface HostedSessionConfig {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
|
||||
// Optional fields for extended configuration.
|
||||
serverName?: string;
|
||||
password?: string;
|
||||
adminPassword?: string;
|
||||
maxDrivers?: number;
|
||||
|
||||
/** Search term for car selection (alternative to carIds) */
|
||||
carSearch?: string;
|
||||
/** Search term for track selection (alternative to trackId) */
|
||||
trackSearch?: string;
|
||||
|
||||
weatherType?: 'static' | 'dynamic';
|
||||
timeOfDay?: 'morning' | 'afternoon' | 'evening' | 'night';
|
||||
sessionDuration?: number;
|
||||
practiceLength?: number;
|
||||
qualifyingLength?: number;
|
||||
warmupLength?: number;
|
||||
raceLength?: number;
|
||||
startType?: 'standing' | 'rolling';
|
||||
restarts?: 'single-file' | 'double-file';
|
||||
damageModel?: 'off' | 'limited' | 'realistic';
|
||||
trackState?: 'auto' | 'clean' | 'moderately-low' | 'moderately-high' | 'optimum';
|
||||
}
|
||||
88
core/automation/domain/types/ScreenRegion.ts
Normal file
88
core/automation/domain/types/ScreenRegion.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Domain Types: ScreenRegion, Point, ElementLocation, LoginDetectionResult
|
||||
*
|
||||
* These are pure data shapes and helpers used across automation.
|
||||
*/
|
||||
|
||||
export interface ScreenRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a point on the screen with x,y coordinates.
|
||||
*/
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the location of a detected UI element on screen.
|
||||
* Contains the center point, bounding box, and confidence score.
|
||||
*/
|
||||
export interface ElementLocation {
|
||||
center: Point;
|
||||
bounds: ScreenRegion;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of login state detection via screen recognition.
|
||||
*/
|
||||
export interface LoginDetectionResult {
|
||||
isLoggedIn: boolean;
|
||||
confidence: number;
|
||||
detectedIndicators: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ScreenRegion from coordinates.
|
||||
*/
|
||||
export function createScreenRegion(x: number, y: number, width: number, height: number): ScreenRegion {
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Point from coordinates.
|
||||
*/
|
||||
export function createPoint(x: number, y: number): Point {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the center point of a ScreenRegion.
|
||||
*/
|
||||
export function getRegionCenter(region: ScreenRegion): Point {
|
||||
return {
|
||||
x: region.x + Math.floor(region.width / 2),
|
||||
y: region.y + Math.floor(region.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is within a screen region.
|
||||
*/
|
||||
export function isPointInRegion(point: Point, region: ScreenRegion): boolean {
|
||||
return (
|
||||
point.x >= region.x &&
|
||||
point.x <= region.x + region.width &&
|
||||
point.y >= region.y &&
|
||||
point.y <= region.y + region.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two screen regions overlap.
|
||||
*/
|
||||
export function regionsOverlap(a: ScreenRegion, b: ScreenRegion): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
);
|
||||
}
|
||||
18
core/automation/domain/value-objects/AuthenticationState.ts
Normal file
18
core/automation/domain/value-objects/AuthenticationState.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Value object representing the user's authentication state with iRacing.
|
||||
*
|
||||
* This is used to track whether the user has a valid session for automation
|
||||
* without GridPilot ever seeing or storing credentials (zero-knowledge design).
|
||||
*/
|
||||
export const AuthenticationState = {
|
||||
/** Authentication status has not yet been checked */
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
/** Valid session exists and is ready for automation */
|
||||
AUTHENTICATED: 'AUTHENTICATED',
|
||||
/** Session was valid but has expired, re-authentication required */
|
||||
EXPIRED: 'EXPIRED',
|
||||
/** User explicitly logged out, clearing the session */
|
||||
LOGGED_OUT: 'LOGGED_OUT',
|
||||
} as const;
|
||||
|
||||
export type AuthenticationState = typeof AuthenticationState[keyof typeof AuthenticationState];
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AuthenticationState } from './AuthenticationState';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface BrowserAuthenticationStateProps {
|
||||
cookiesValid: boolean;
|
||||
pageAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export class BrowserAuthenticationState implements IValueObject<BrowserAuthenticationStateProps> {
|
||||
private readonly cookiesValid: boolean;
|
||||
private readonly pageAuthenticated: boolean;
|
||||
|
||||
constructor(cookiesValid: boolean, pageAuthenticated: boolean) {
|
||||
this.cookiesValid = cookiesValid;
|
||||
this.pageAuthenticated = pageAuthenticated;
|
||||
}
|
||||
|
||||
isFullyAuthenticated(): boolean {
|
||||
return this.cookiesValid && this.pageAuthenticated;
|
||||
}
|
||||
|
||||
getAuthenticationState(): AuthenticationState {
|
||||
if (!this.cookiesValid) {
|
||||
return AuthenticationState.UNKNOWN;
|
||||
}
|
||||
|
||||
if (!this.pageAuthenticated) {
|
||||
return AuthenticationState.EXPIRED;
|
||||
}
|
||||
|
||||
return AuthenticationState.AUTHENTICATED;
|
||||
}
|
||||
|
||||
requiresReauthentication(): boolean {
|
||||
return !this.isFullyAuthenticated();
|
||||
}
|
||||
|
||||
getCookieValidity(): boolean {
|
||||
return this.cookiesValid;
|
||||
}
|
||||
|
||||
getPageAuthenticationStatus(): boolean {
|
||||
return this.pageAuthenticated;
|
||||
}
|
||||
|
||||
get props(): BrowserAuthenticationStateProps {
|
||||
return {
|
||||
cookiesValid: this.cookiesValid,
|
||||
pageAuthenticated: this.pageAuthenticated,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<BrowserAuthenticationStateProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.cookiesValid === b.cookiesValid && a.pageAuthenticated === b.pageAuthenticated;
|
||||
}
|
||||
}
|
||||
54
core/automation/domain/value-objects/CheckoutConfirmation.ts
Normal file
54
core/automation/domain/value-objects/CheckoutConfirmation.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type CheckoutConfirmationDecision = 'confirmed' | 'cancelled' | 'timeout';
|
||||
|
||||
const VALID_DECISIONS: CheckoutConfirmationDecision[] = [
|
||||
'confirmed',
|
||||
'cancelled',
|
||||
'timeout',
|
||||
];
|
||||
|
||||
export class CheckoutConfirmation {
|
||||
private readonly _value: CheckoutConfirmationDecision;
|
||||
|
||||
private constructor(value: CheckoutConfirmationDecision) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
static create(value: CheckoutConfirmationDecision): CheckoutConfirmation {
|
||||
if (!VALID_DECISIONS.includes(value)) {
|
||||
throw new Error('Invalid checkout confirmation decision');
|
||||
}
|
||||
return new CheckoutConfirmation(value);
|
||||
}
|
||||
|
||||
static confirmed(): CheckoutConfirmation {
|
||||
return CheckoutConfirmation.create('confirmed');
|
||||
}
|
||||
|
||||
static cancelled(_reason?: string): CheckoutConfirmation {
|
||||
return CheckoutConfirmation.create('cancelled');
|
||||
}
|
||||
|
||||
static timeout(): CheckoutConfirmation {
|
||||
return CheckoutConfirmation.create('timeout');
|
||||
}
|
||||
|
||||
get value(): CheckoutConfirmationDecision {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: CheckoutConfirmation): boolean {
|
||||
return this._value === other._value;
|
||||
}
|
||||
|
||||
isConfirmed(): boolean {
|
||||
return this._value === 'confirmed';
|
||||
}
|
||||
|
||||
isCancelled(): boolean {
|
||||
return this._value === 'cancelled';
|
||||
}
|
||||
|
||||
isTimeout(): boolean {
|
||||
return this._value === 'timeout';
|
||||
}
|
||||
}
|
||||
73
core/automation/domain/value-objects/CheckoutPrice.ts
Normal file
73
core/automation/domain/value-objects/CheckoutPrice.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface CheckoutPriceProps {
|
||||
amountUsd: number;
|
||||
}
|
||||
|
||||
export class CheckoutPrice implements IValueObject<CheckoutPriceProps> {
|
||||
private constructor(private readonly amountUsd: number) {
|
||||
if (amountUsd < 0) {
|
||||
throw new Error('Price cannot be negative');
|
||||
}
|
||||
if (amountUsd > 10000) {
|
||||
throw new Error('Price exceeds maximum of $10,000');
|
||||
}
|
||||
}
|
||||
|
||||
static fromString(priceStr: string): CheckoutPrice {
|
||||
const trimmed = priceStr.trim();
|
||||
|
||||
if (!trimmed.startsWith('$')) {
|
||||
throw new Error('Invalid price format: missing dollar sign');
|
||||
}
|
||||
|
||||
const dollarSignCount = (trimmed.match(/\$/g) || []).length;
|
||||
if (dollarSignCount > 1) {
|
||||
throw new Error('Invalid price format: multiple dollar signs');
|
||||
}
|
||||
|
||||
const numericPart = trimmed.substring(1).replace(/,/g, '');
|
||||
|
||||
if (numericPart === '') {
|
||||
throw new Error('Invalid price format: no numeric value');
|
||||
}
|
||||
|
||||
const amount = parseFloat(numericPart);
|
||||
|
||||
if (isNaN(amount)) {
|
||||
throw new Error('Invalid price format: not a valid number');
|
||||
}
|
||||
|
||||
return new CheckoutPrice(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for a neutral/zero checkout price.
|
||||
* Used when no explicit price can be extracted from the DOM.
|
||||
*/
|
||||
static zero(): CheckoutPrice {
|
||||
return new CheckoutPrice(0);
|
||||
}
|
||||
|
||||
toDisplayString(): string {
|
||||
return `$${this.amountUsd.toFixed(2)}`;
|
||||
}
|
||||
|
||||
getAmount(): number {
|
||||
return this.amountUsd;
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.amountUsd < 0.001;
|
||||
}
|
||||
|
||||
get props(): CheckoutPriceProps {
|
||||
return {
|
||||
amountUsd: this.amountUsd,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<CheckoutPriceProps>): boolean {
|
||||
return this.props.amountUsd === other.props.amountUsd;
|
||||
}
|
||||
}
|
||||
51
core/automation/domain/value-objects/CheckoutState.ts
Normal file
51
core/automation/domain/value-objects/CheckoutState.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export enum CheckoutStateEnum {
|
||||
READY = 'READY',
|
||||
INSUFFICIENT_FUNDS = 'INSUFFICIENT_FUNDS',
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export class CheckoutState {
|
||||
private constructor(private readonly state: CheckoutStateEnum) {}
|
||||
|
||||
static ready(): CheckoutState {
|
||||
return new CheckoutState(CheckoutStateEnum.READY);
|
||||
}
|
||||
|
||||
static insufficientFunds(): CheckoutState {
|
||||
return new CheckoutState(CheckoutStateEnum.INSUFFICIENT_FUNDS);
|
||||
}
|
||||
|
||||
static unknown(): CheckoutState {
|
||||
return new CheckoutState(CheckoutStateEnum.UNKNOWN);
|
||||
}
|
||||
|
||||
static fromButtonClasses(classes: string): CheckoutState {
|
||||
const normalized = classes.toLowerCase().trim();
|
||||
|
||||
if (normalized.includes('btn-success')) {
|
||||
return CheckoutState.ready();
|
||||
}
|
||||
|
||||
if (normalized.includes('btn')) {
|
||||
return CheckoutState.insufficientFunds();
|
||||
}
|
||||
|
||||
return CheckoutState.unknown();
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.state === CheckoutStateEnum.READY;
|
||||
}
|
||||
|
||||
hasInsufficientFunds(): boolean {
|
||||
return this.state === CheckoutStateEnum.INSUFFICIENT_FUNDS;
|
||||
}
|
||||
|
||||
isUnknown(): boolean {
|
||||
return this.state === CheckoutStateEnum.UNKNOWN;
|
||||
}
|
||||
|
||||
getValue(): CheckoutStateEnum {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
104
core/automation/domain/value-objects/CookieConfiguration.ts
Normal file
104
core/automation/domain/value-objects/CookieConfiguration.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: 'Strict' | 'Lax' | 'None';
|
||||
}
|
||||
|
||||
export class CookieConfiguration {
|
||||
private readonly cookie: Cookie;
|
||||
private readonly targetUrl: URL;
|
||||
|
||||
constructor(cookie: Cookie, targetUrl: string) {
|
||||
this.cookie = cookie;
|
||||
try {
|
||||
this.targetUrl = new URL(targetUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid target URL: ${targetUrl}`);
|
||||
}
|
||||
|
||||
this.validate();
|
||||
}
|
||||
|
||||
private validate(): void {
|
||||
if (!this.isValidDomain()) {
|
||||
throw new Error(
|
||||
`Domain mismatch: Cookie domain "${this.cookie.domain}" is invalid for target "${this.targetUrl.hostname}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isValidPath()) {
|
||||
throw new Error(
|
||||
`Path not valid: Cookie path "${this.cookie.path}" is invalid for target path "${this.targetUrl.pathname}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidDomain(): boolean {
|
||||
const targetHost = this.targetUrl.hostname;
|
||||
const cookieDomain = this.cookie.domain;
|
||||
|
||||
// Empty domain is invalid
|
||||
if (!cookieDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (cookieDomain === targetHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard domain (e.g., ".iracing.com" matches "members-ng.iracing.com")
|
||||
if (cookieDomain.startsWith('.')) {
|
||||
const domainWithoutDot = cookieDomain.slice(1);
|
||||
return targetHost === domainWithoutDot || targetHost.endsWith('.' + domainWithoutDot);
|
||||
}
|
||||
|
||||
// Subdomain compatibility: Allow cookies from related subdomains if they share the same base domain
|
||||
// Example: "members.iracing.com" → "members-ng.iracing.com" (both share "iracing.com")
|
||||
if (this.isSameBaseDomain(cookieDomain, targetHost)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two domains share the same base domain (last 2 parts)
|
||||
* @example
|
||||
* isSameBaseDomain('members.iracing.com', 'members-ng.iracing.com') // true
|
||||
* isSameBaseDomain('example.com', 'iracing.com') // false
|
||||
*/
|
||||
private isSameBaseDomain(domain1: string, domain2: string): boolean {
|
||||
const parts1 = domain1.split('.');
|
||||
const parts2 = domain2.split('.');
|
||||
|
||||
// Need at least 2 parts (domain.tld) for valid comparison
|
||||
if (parts1.length < 2 || parts2.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare last 2 parts (e.g., "iracing.com")
|
||||
const base1 = parts1.slice(-2).join('.');
|
||||
const base2 = parts2.slice(-2).join('.');
|
||||
|
||||
return base1 === base2;
|
||||
}
|
||||
|
||||
private isValidPath(): boolean {
|
||||
// Empty path is invalid
|
||||
if (!this.cookie.path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Path must be prefix of target pathname
|
||||
return this.targetUrl.pathname.startsWith(this.cookie.path);
|
||||
}
|
||||
|
||||
getValidatedCookie(): Cookie {
|
||||
return { ...this.cookie };
|
||||
}
|
||||
}
|
||||
55
core/automation/domain/value-objects/RaceCreationResult.ts
Normal file
55
core/automation/domain/value-objects/RaceCreationResult.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface RaceCreationResultData {
|
||||
sessionId: string;
|
||||
price: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class RaceCreationResult {
|
||||
private readonly _sessionId: string;
|
||||
private readonly _price: string;
|
||||
private readonly _timestamp: Date;
|
||||
|
||||
private constructor(data: RaceCreationResultData) {
|
||||
this._sessionId = data.sessionId;
|
||||
this._price = data.price;
|
||||
this._timestamp = data.timestamp;
|
||||
}
|
||||
|
||||
static create(data: RaceCreationResultData): RaceCreationResult {
|
||||
if (!data.sessionId || data.sessionId.trim() === '') {
|
||||
throw new Error('Session ID cannot be empty');
|
||||
}
|
||||
if (!data.price || data.price.trim() === '') {
|
||||
throw new Error('Price cannot be empty');
|
||||
}
|
||||
return new RaceCreationResult(data);
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
get price(): string {
|
||||
return this._price;
|
||||
}
|
||||
|
||||
get timestamp(): Date {
|
||||
return this._timestamp;
|
||||
}
|
||||
|
||||
equals(other: RaceCreationResult): boolean {
|
||||
return (
|
||||
this._sessionId === other._sessionId &&
|
||||
this._price === other._price &&
|
||||
this._timestamp.getTime() === other._timestamp.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): { sessionId: string; price: string; timestamp: string } {
|
||||
return {
|
||||
sessionId: this._sessionId,
|
||||
price: this._price,
|
||||
timestamp: this._timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
86
core/automation/domain/value-objects/ScreenRegion.ts
Normal file
86
core/automation/domain/value-objects/ScreenRegion.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Represents a rectangular region on the screen.
|
||||
* Used for targeted screen capture and element location.
|
||||
*/
|
||||
export interface ScreenRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a point on the screen with x,y coordinates.
|
||||
*/
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the location of a detected UI element on screen.
|
||||
* Contains the center point, bounding box, and confidence score.
|
||||
*/
|
||||
export interface ElementLocation {
|
||||
center: Point;
|
||||
bounds: ScreenRegion;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of login state detection via screen recognition.
|
||||
*/
|
||||
export interface LoginDetectionResult {
|
||||
isLoggedIn: boolean;
|
||||
confidence: number;
|
||||
detectedIndicators: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ScreenRegion from coordinates.
|
||||
*/
|
||||
export function createScreenRegion(x: number, y: number, width: number, height: number): ScreenRegion {
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Point from coordinates.
|
||||
*/
|
||||
export function createPoint(x: number, y: number): Point {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the center point of a ScreenRegion.
|
||||
*/
|
||||
export function getRegionCenter(region: ScreenRegion): Point {
|
||||
return {
|
||||
x: region.x + Math.floor(region.width / 2),
|
||||
y: region.y + Math.floor(region.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a point is within a screen region.
|
||||
*/
|
||||
export function isPointInRegion(point: Point, region: ScreenRegion): boolean {
|
||||
return (
|
||||
point.x >= region.x &&
|
||||
point.x <= region.x + region.width &&
|
||||
point.y >= region.y &&
|
||||
point.y <= region.y + region.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two screen regions overlap.
|
||||
*/
|
||||
export function regionsOverlap(a: ScreenRegion, b: ScreenRegion): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
);
|
||||
}
|
||||
107
core/automation/domain/value-objects/SessionLifetime.ts
Normal file
107
core/automation/domain/value-objects/SessionLifetime.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SessionLifetime Value Object
|
||||
*
|
||||
* Represents the lifetime of an authentication session with expiry tracking.
|
||||
* Handles validation of session expiry dates with a configurable buffer window.
|
||||
*/
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface SessionLifetimeProps {
|
||||
expiry: Date | null;
|
||||
bufferMinutes: number;
|
||||
}
|
||||
|
||||
export class SessionLifetime implements IValueObject<SessionLifetimeProps> {
|
||||
private readonly expiry: Date | null;
|
||||
private readonly bufferMinutes: number;
|
||||
|
||||
constructor(expiry: Date | null, bufferMinutes: number = 5) {
|
||||
if (expiry !== null) {
|
||||
if (isNaN(expiry.getTime())) {
|
||||
throw new Error('Invalid expiry date provided');
|
||||
}
|
||||
|
||||
// Allow dates within buffer window to support checking expiry of recently expired sessions
|
||||
const bufferMs = bufferMinutes * 60 * 1000;
|
||||
const expiryWithBuffer = expiry.getTime() + bufferMs;
|
||||
if (expiryWithBuffer < Date.now()) {
|
||||
throw new Error('Expiry date cannot be in the past');
|
||||
}
|
||||
}
|
||||
|
||||
this.expiry = expiry;
|
||||
this.bufferMinutes = bufferMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session is expired.
|
||||
* Considers the buffer time - sessions within the buffer window are treated as expired.
|
||||
*
|
||||
* @returns true if expired or expiring soon (within buffer), false otherwise
|
||||
*/
|
||||
isExpired(): boolean {
|
||||
if (this.expiry === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bufferMs = this.bufferMinutes * 60 * 1000;
|
||||
const expiryWithBuffer = this.expiry.getTime() - bufferMs;
|
||||
return Date.now() >= expiryWithBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session is expiring soon (within buffer window).
|
||||
*
|
||||
* @returns true if expiring within buffer window, false otherwise
|
||||
*/
|
||||
isExpiringSoon(): boolean {
|
||||
if (this.expiry === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bufferMs = this.bufferMinutes * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const expiryTime = this.expiry.getTime();
|
||||
const expiryWithBuffer = expiryTime - bufferMs;
|
||||
|
||||
return now >= expiryWithBuffer && now < expiryTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiry date.
|
||||
*
|
||||
* @returns The expiry date or null if no expiration
|
||||
*/
|
||||
getExpiry(): Date | null {
|
||||
return this.expiry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time until expiry in milliseconds.
|
||||
*
|
||||
* @returns Milliseconds until expiry, or Infinity if no expiration
|
||||
*/
|
||||
getRemainingTime(): number {
|
||||
if (this.expiry === null) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
const remaining = this.expiry.getTime() - Date.now();
|
||||
return Math.max(0, remaining);
|
||||
}
|
||||
|
||||
get props(): SessionLifetimeProps {
|
||||
return {
|
||||
expiry: this.expiry,
|
||||
bufferMinutes: this.bufferMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SessionLifetimeProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
const aExpiry = a.expiry?.getTime() ?? null;
|
||||
const bExpiry = b.expiry?.getTime() ?? null;
|
||||
return aExpiry === bExpiry && a.bufferMinutes === b.bufferMinutes;
|
||||
}
|
||||
}
|
||||
106
core/automation/domain/value-objects/SessionState.ts
Normal file
106
core/automation/domain/value-objects/SessionState.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export type SessionStateValue =
|
||||
| 'PENDING'
|
||||
| 'IN_PROGRESS'
|
||||
| 'PAUSED'
|
||||
| 'COMPLETED'
|
||||
| 'FAILED'
|
||||
| 'STOPPED_AT_STEP_18'
|
||||
| 'AWAITING_CHECKOUT_CONFIRMATION'
|
||||
| 'CANCELLED';
|
||||
|
||||
const VALID_STATES: SessionStateValue[] = [
|
||||
'PENDING',
|
||||
'IN_PROGRESS',
|
||||
'PAUSED',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'STOPPED_AT_STEP_18',
|
||||
'AWAITING_CHECKOUT_CONFIRMATION',
|
||||
'CANCELLED',
|
||||
];
|
||||
|
||||
const VALID_TRANSITIONS: Record<SessionStateValue, SessionStateValue[]> = {
|
||||
PENDING: ['IN_PROGRESS', 'FAILED'],
|
||||
IN_PROGRESS: ['PAUSED', 'COMPLETED', 'FAILED', 'STOPPED_AT_STEP_18', 'AWAITING_CHECKOUT_CONFIRMATION'],
|
||||
PAUSED: ['IN_PROGRESS', 'FAILED'],
|
||||
COMPLETED: [],
|
||||
FAILED: [],
|
||||
STOPPED_AT_STEP_18: [],
|
||||
AWAITING_CHECKOUT_CONFIRMATION: ['COMPLETED', 'CANCELLED', 'FAILED'],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
export interface SessionStateProps {
|
||||
value: SessionStateValue;
|
||||
}
|
||||
|
||||
export class SessionState implements IValueObject<SessionStateProps> {
|
||||
private readonly _value: SessionStateValue;
|
||||
|
||||
private constructor(value: SessionStateValue) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
static create(value: SessionStateValue): SessionState {
|
||||
if (!VALID_STATES.includes(value)) {
|
||||
throw new Error('Invalid session state');
|
||||
}
|
||||
return new SessionState(value);
|
||||
}
|
||||
|
||||
get value(): SessionStateValue {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
isPending(): boolean {
|
||||
return this._value === 'PENDING';
|
||||
}
|
||||
|
||||
isInProgress(): boolean {
|
||||
return this._value === 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
isCompleted(): boolean {
|
||||
return this._value === 'COMPLETED';
|
||||
}
|
||||
|
||||
isFailed(): boolean {
|
||||
return this._value === 'FAILED';
|
||||
}
|
||||
|
||||
isStoppedAtStep18(): boolean {
|
||||
return this._value === 'STOPPED_AT_STEP_18';
|
||||
}
|
||||
|
||||
isAwaitingCheckoutConfirmation(): boolean {
|
||||
return this._value === 'AWAITING_CHECKOUT_CONFIRMATION';
|
||||
}
|
||||
|
||||
isCancelled(): boolean {
|
||||
return this._value === 'CANCELLED';
|
||||
}
|
||||
|
||||
canTransitionTo(targetState: SessionState): boolean {
|
||||
const allowedTransitions = VALID_TRANSITIONS[this._value];
|
||||
return allowedTransitions.includes(targetState._value);
|
||||
}
|
||||
|
||||
isTerminal(): boolean {
|
||||
return (
|
||||
this._value === 'COMPLETED' ||
|
||||
this._value === 'FAILED' ||
|
||||
this._value === 'STOPPED_AT_STEP_18' ||
|
||||
this._value === 'CANCELLED'
|
||||
);
|
||||
}
|
||||
|
||||
get props(): SessionStateProps {
|
||||
return { value: this._value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<SessionStateProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
50
core/automation/domain/value-objects/StepId.ts
Normal file
50
core/automation/domain/value-objects/StepId.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface StepIdProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class StepId implements IValueObject<StepIdProps> {
|
||||
private readonly _value: number;
|
||||
|
||||
private constructor(value: number) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
static create(value: number): StepId {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error('StepId must be an integer');
|
||||
}
|
||||
if (value < 1 || value > 17) {
|
||||
throw new Error('StepId must be between 1 and 17');
|
||||
}
|
||||
return new StepId(value);
|
||||
}
|
||||
|
||||
get value(): number {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
isModalStep(): boolean {
|
||||
return this._value === 6 || this._value === 9 || this._value === 12;
|
||||
}
|
||||
|
||||
isFinalStep(): boolean {
|
||||
return this._value === 17;
|
||||
}
|
||||
|
||||
next(): StepId {
|
||||
if (this.isFinalStep()) {
|
||||
throw new Error('Cannot advance beyond final step');
|
||||
}
|
||||
return StepId.create(this._value + 1);
|
||||
}
|
||||
|
||||
get props(): StepIdProps {
|
||||
return { value: this._value };
|
||||
}
|
||||
|
||||
equals(other: IValueObject<StepIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
18
core/automation/index.ts
Normal file
18
core/automation/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export * from './domain/value-objects/StepId';
|
||||
export * from './domain/value-objects/CheckoutState';
|
||||
export * from './domain/value-objects/RaceCreationResult';
|
||||
export * from './domain/value-objects/CheckoutPrice';
|
||||
export * from './domain/value-objects/CheckoutConfirmation';
|
||||
export * from './domain/value-objects/AuthenticationState';
|
||||
export * from './domain/value-objects/BrowserAuthenticationState';
|
||||
export * from './domain/value-objects/CookieConfiguration';
|
||||
export * from './domain/value-objects/ScreenRegion';
|
||||
export * from './domain/value-objects/SessionLifetime';
|
||||
export * from './domain/value-objects/SessionState';
|
||||
|
||||
export * from './domain/entities/AutomationSession';
|
||||
export * from './domain/types/HostedSessionConfig';
|
||||
export * from './domain/entities/StepExecution';
|
||||
|
||||
export * from './domain/services/PageStateValidator';
|
||||
export * from './domain/services/StepTransitionValidator';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AutomationEvent } from '@gridpilot/automation/application/ports/AutomationEventPublisherPort';
|
||||
|
||||
export type LifecycleCallback = (event: AutomationEvent) => Promise<void> | void;
|
||||
|
||||
export interface IAutomationLifecycleEmitter {
|
||||
onLifecycle(cb: LifecycleCallback): void;
|
||||
offLifecycle(cb: LifecycleCallback): void;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Result } from '../../../../shared/result/Result';
|
||||
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
|
||||
import type { CheckoutInfoDTO } from '../../../application/dto/CheckoutInfoDTO';
|
||||
import { IRACING_SELECTORS } from './dom/IRacingSelectors';
|
||||
|
||||
interface Page {
|
||||
locator(selector: string): Locator;
|
||||
}
|
||||
|
||||
interface Locator {
|
||||
first(): Locator;
|
||||
locator(selector: string): Locator;
|
||||
getAttribute(name: string): Promise<string | null>;
|
||||
innerHTML(): Promise<string>;
|
||||
textContent(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export class CheckoutPriceExtractor {
|
||||
// Use the price action selector from IRACING_SELECTORS
|
||||
private readonly selector = IRACING_SELECTORS.BLOCKED_SELECTORS.priceAction;
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>> {
|
||||
try {
|
||||
// Prefer the explicit pill element which contains the price
|
||||
const pillLocator = this.page.locator('.label-pill, .label-inverse');
|
||||
const pillText = await pillLocator.first().textContent().catch(() => null);
|
||||
|
||||
let price: CheckoutPrice | null = null;
|
||||
let state = CheckoutState.unknown();
|
||||
let buttonHtml = '';
|
||||
|
||||
if (pillText) {
|
||||
// Parse price if possible
|
||||
try {
|
||||
price = CheckoutPrice.fromString(pillText.trim());
|
||||
} catch {
|
||||
price = null;
|
||||
}
|
||||
|
||||
// Try to find the containing button and its classes/html
|
||||
// Primary: locate button via known selector that contains the pill
|
||||
const buttonLocator = this.page.locator(this.selector).first();
|
||||
let classes = await buttonLocator.getAttribute('class').catch(() => null);
|
||||
let html = await buttonLocator.innerHTML().catch(() => '');
|
||||
|
||||
if (!classes) {
|
||||
// Fallback: find ancestor <a> of the pill (XPath)
|
||||
const ancestorButton = pillLocator.first().locator('xpath=ancestor::a[1]');
|
||||
classes = await ancestorButton.getAttribute('class').catch(() => null);
|
||||
html = await ancestorButton.innerHTML().catch(() => '');
|
||||
}
|
||||
|
||||
if (classes) {
|
||||
state = CheckoutState.fromButtonClasses(classes);
|
||||
buttonHtml = html ?? '';
|
||||
}
|
||||
} else {
|
||||
// No pill found — attempt to read button directly (best-effort)
|
||||
const buttonLocator = this.page.locator(this.selector).first();
|
||||
const classes = await buttonLocator.getAttribute('class').catch(() => null);
|
||||
const html = await buttonLocator.innerHTML().catch(() => '');
|
||||
|
||||
if (classes) {
|
||||
state = CheckoutState.fromButtonClasses(classes);
|
||||
buttonHtml = html ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Additional fallback: search the wizard-footer for any price text if pill was not present or parsing failed
|
||||
if (!price) {
|
||||
try {
|
||||
const footerLocator = this.page.locator('.wizard-footer, .modal-footer').first();
|
||||
const footerText = await footerLocator.textContent().catch(() => null);
|
||||
if (footerText) {
|
||||
const match = footerText.match(/\$\d+\.\d{2}/);
|
||||
if (match) {
|
||||
try {
|
||||
price = CheckoutPrice.fromString(match[0]);
|
||||
} catch {
|
||||
price = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore footer parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
price,
|
||||
state,
|
||||
buttonHtml
|
||||
});
|
||||
} catch (error) {
|
||||
// On any unexpected error, return an "unknown" result (do not throw)
|
||||
return Result.ok({
|
||||
price: null,
|
||||
state: CheckoutState.unknown(),
|
||||
buttonHtml: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Page } from 'playwright';
|
||||
import { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||
|
||||
export class AuthenticationGuard {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly logger?: LoggerPort
|
||||
) {}
|
||||
|
||||
async checkForLoginUI(): Promise<boolean> {
|
||||
const loginSelectors = [
|
||||
'text="You are not logged in"',
|
||||
':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")',
|
||||
'button[aria-label="Log in"]',
|
||||
];
|
||||
|
||||
for (const selector of loginSelectors) {
|
||||
try {
|
||||
const element = this.page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
this.logger?.warn('Login UI detected - user not authenticated', {
|
||||
selector,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Selector not found, continue checking
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async failFastIfUnauthenticated(): Promise<void> {
|
||||
if (await this.checkForLoginUI()) {
|
||||
throw new Error('Authentication required: Login UI detected on page');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
|
||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||
|
||||
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
|
||||
constructor(private readonly logger?: LoggerPort) {}
|
||||
|
||||
getLoginUrl(): string {
|
||||
return IRACING_URLS.login;
|
||||
}
|
||||
|
||||
getPostLoginLandingUrl(): string {
|
||||
return IRACING_URLS.hostedSessions;
|
||||
}
|
||||
|
||||
isLoginUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower.includes('oauth.iracing.com') ||
|
||||
lower.includes('/membersite/login') ||
|
||||
lower.includes('/login.jsp') ||
|
||||
lower.includes('/login')
|
||||
);
|
||||
}
|
||||
|
||||
isAuthenticatedUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower.includes('/web/racing/hosted') ||
|
||||
lower.includes('/membersite/member') ||
|
||||
lower.includes('members-ng.iracing.com') ||
|
||||
lower.startsWith(IRACING_URLS.hostedSessions.toLowerCase()) ||
|
||||
lower.startsWith(IRACING_URLS.home.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
isLoginSuccessUrl(url: string): boolean {
|
||||
return this.isAuthenticatedUrl(url) && !this.isLoginUrl(url);
|
||||
}
|
||||
|
||||
async detectAuthenticatedUi(page: Page): Promise<boolean> {
|
||||
const authSelectors = [
|
||||
IRACING_SELECTORS.hostedRacing.createRaceButton,
|
||||
'[aria-label*="user menu" i]',
|
||||
'[aria-label*="account menu" i]',
|
||||
'.user-menu',
|
||||
'.account-menu',
|
||||
'nav a[href*="/membersite"]',
|
||||
'nav a[href*="/members"]',
|
||||
];
|
||||
|
||||
for (const selector of authSelectors) {
|
||||
try {
|
||||
const element = page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
this.logger?.info?.('Authenticated UI detected', { selector });
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore selector errors, try next selector
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async detectLoginUi(page: Page): Promise<boolean> {
|
||||
const guard = new AuthenticationGuard(page, this.logger);
|
||||
return guard.checkForLoginUI();
|
||||
}
|
||||
|
||||
async navigateToAuthenticatedArea(page: Page): Promise<void> {
|
||||
await page.goto(this.getPostLoginLandingUrl(), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
this.logger?.info?.('Waiting for post-login redirect', { timeoutMs });
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (page.isClosed()) {
|
||||
this.logger?.warn?.('Page closed while waiting for post-login redirect');
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = page.url();
|
||||
|
||||
if (this.isLoginSuccessUrl(url)) {
|
||||
this.logger?.info?.('Login success detected by URL', { url });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: detect authenticated UI even if URL is not the canonical one
|
||||
const hasAuthUi = await this.detectAuthenticatedUi(page);
|
||||
if (hasAuthUi) {
|
||||
this.logger?.info?.('Login success detected by authenticated UI', { url });
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger?.debug?.('Error while waiting for post-login redirect', { error: message });
|
||||
|
||||
if (page.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger?.warn?.('Post-login redirect wait timed out', { timeoutMs });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
/**
|
||||
* Infra-level abstraction for Playwright-based authentication flows.
|
||||
*
|
||||
* Encapsulates game/site-specific URL patterns and UI detection so that
|
||||
* auth/session orchestration can remain generic and reusable.
|
||||
*/
|
||||
export interface IPlaywrightAuthFlow {
|
||||
/** Get the URL of the login page. */
|
||||
getLoginUrl(): string;
|
||||
|
||||
/**
|
||||
* Get a canonical URL that indicates the user is in an authenticated
|
||||
* area suitable for running automation (e.g. hosted sessions dashboard).
|
||||
*/
|
||||
getPostLoginLandingUrl(): string;
|
||||
|
||||
/** True if the given URL points at the login experience. */
|
||||
isLoginUrl(url: string): boolean;
|
||||
|
||||
/** True if the given URL is considered authenticated (members area). */
|
||||
isAuthenticatedUrl(url: string): boolean;
|
||||
|
||||
/**
|
||||
* True if the URL represents a successful login redirect, distinct from
|
||||
* the raw login form page or intermediate OAuth pages.
|
||||
*/
|
||||
isLoginSuccessUrl(url: string): boolean;
|
||||
|
||||
/** Detect whether an authenticated UI is currently rendered. */
|
||||
detectAuthenticatedUi(page: Page): Promise<boolean>;
|
||||
|
||||
/** Detect whether a login UI is currently rendered. */
|
||||
detectLoginUi(page: Page): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Navigate the given page into an authenticated area that the automation
|
||||
* engine can assume as a starting point after login.
|
||||
*/
|
||||
navigateToAuthenticatedArea(page: Page): Promise<void>;
|
||||
|
||||
/**
|
||||
* Wait for the browser to reach a post-login state within the timeout.
|
||||
*
|
||||
* Implementations may use URL changes, UI detection, or a combination of
|
||||
* both to determine success.
|
||||
*/
|
||||
waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import * as fs from 'fs';
|
||||
import type { BrowserContext, Page } from 'playwright';
|
||||
|
||||
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
|
||||
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../../../shared/result/Result';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
import { SessionCookieStore } from './SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||
|
||||
interface PlaywrightAuthSessionConfig {
|
||||
navigationTimeoutMs?: number;
|
||||
loginWaitTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game-agnostic Playwright-based authentication/session service.
|
||||
*
|
||||
* All game/site-specific behavior (URLs, selectors, redirects) is delegated to
|
||||
* the injected IPlaywrightAuthFlow implementation. This class is responsible
|
||||
* only for:
|
||||
* - Browser/session orchestration via PlaywrightBrowserSession
|
||||
* - Cookie persistence via SessionCookieStore
|
||||
* - Exposing the IAuthenticationService port for application layer
|
||||
*/
|
||||
export class PlaywrightAuthSessionService implements AuthenticationServicePort {
|
||||
private readonly browserSession: PlaywrightBrowserSession;
|
||||
private readonly cookieStore: SessionCookieStore;
|
||||
private readonly authFlow: IPlaywrightAuthFlow;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
|
||||
private readonly navigationTimeoutMs: number;
|
||||
private readonly loginWaitTimeoutMs: number;
|
||||
|
||||
private authState: AuthenticationState = AuthenticationState.UNKNOWN;
|
||||
|
||||
constructor(
|
||||
browserSession: PlaywrightBrowserSession,
|
||||
cookieStore: SessionCookieStore,
|
||||
authFlow: IPlaywrightAuthFlow,
|
||||
logger?: LoggerPort,
|
||||
config?: PlaywrightAuthSessionConfig,
|
||||
) {
|
||||
this.browserSession = browserSession;
|
||||
this.cookieStore = cookieStore;
|
||||
this.authFlow = authFlow;
|
||||
this.logger = logger;
|
||||
|
||||
this.navigationTimeoutMs = config?.navigationTimeoutMs ?? 30000;
|
||||
this.loginWaitTimeoutMs = config?.loginWaitTimeoutMs ?? 300000;
|
||||
}
|
||||
|
||||
// ===== Logging =====
|
||||
|
||||
private log(
|
||||
level: 'debug' | 'info' | 'warn' | 'error',
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private getContext(): BrowserContext | null {
|
||||
return this.browserSession.getPersistentContext() ?? this.browserSession.getContext();
|
||||
}
|
||||
|
||||
private getPageOrError(): Result<Page> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) {
|
||||
return Result.err(new Error('Browser not connected'));
|
||||
}
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
private async injectCookiesBeforeNavigation(targetUrl: string): Promise<Result<void>> {
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
return Result.err(new Error('No browser context available'));
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state || state.cookies.length === 0) {
|
||||
return Result.err(new Error('No cookies found in session store'));
|
||||
}
|
||||
|
||||
const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
if (validCookies.length === 0) {
|
||||
this.log('warn', 'No valid cookies found for target URL', {
|
||||
targetUrl,
|
||||
totalCookies: state.cookies.length,
|
||||
});
|
||||
return Result.err(new Error('No valid cookies found for target URL'));
|
||||
}
|
||||
|
||||
await context.addCookies(validCookies);
|
||||
|
||||
this.log('info', 'Cookies injected successfully', {
|
||||
count: validCookies.length,
|
||||
targetUrl,
|
||||
cookieNames: validCookies.map((c) => c.name),
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(new Error(`Cookie injection failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSessionState(): Promise<void> {
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
this.log('warn', 'No browser context available to save session state');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storageState = await context.storageState();
|
||||
await this.cookieStore.write(storageState);
|
||||
this.log('info', 'Session state saved to cookie store');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to save session state', { error: message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IAuthenticationService implementation =====
|
||||
|
||||
async checkSession(): Promise<Result<AuthenticationState>> {
|
||||
try {
|
||||
this.log('info', 'Checking session from cookie store');
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state) {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('info', 'No session state file found');
|
||||
return Result.ok(this.authState);
|
||||
}
|
||||
|
||||
this.authState = this.cookieStore.validateCookies(state.cookies);
|
||||
this.log('info', 'Session check complete', { state: this.authState });
|
||||
return Result.ok(this.authState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Session check failed', { error: message });
|
||||
return Result.err(new Error(`Session check failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
getLoginUrl(): string {
|
||||
return this.authFlow.getLoginUrl();
|
||||
}
|
||||
|
||||
async initiateLogin(): Promise<Result<void>> {
|
||||
try {
|
||||
const forceHeaded = true;
|
||||
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
|
||||
forceHeaded,
|
||||
});
|
||||
|
||||
const connectResult = await this.browserSession.connect(forceHeaded);
|
||||
if (!connectResult.success) {
|
||||
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
|
||||
}
|
||||
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
const loginUrl = this.authFlow.getLoginUrl();
|
||||
await page.goto(loginUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
this.log('info', forceHeaded
|
||||
? 'Browser opened to login page in headed mode, waiting for login...'
|
||||
: 'Browser opened to login page, waiting for login...');
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
|
||||
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
|
||||
page,
|
||||
this.loginWaitTimeoutMs,
|
||||
);
|
||||
|
||||
if (loginSuccess) {
|
||||
this.log('info', 'Login detected, saving session state');
|
||||
await this.saveSessionState();
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
|
||||
this.authState = AuthenticationState.AUTHENTICATED;
|
||||
this.log('info', 'Session saved and validated successfully');
|
||||
} else {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('warn', 'Session saved but validation unclear');
|
||||
}
|
||||
|
||||
this.log('info', 'Closing browser after successful login');
|
||||
await this.browserSession.disconnect();
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
this.log('warn', 'Login was not completed');
|
||||
await this.browserSession.disconnect();
|
||||
return Result.err(new Error('Login timeout - please try again'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed during login process', { error: message });
|
||||
|
||||
try {
|
||||
await this.browserSession.disconnect();
|
||||
} catch {
|
||||
}
|
||||
|
||||
return Result.err(error instanceof Error ? error : new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async confirmLoginComplete(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'User confirmed login complete');
|
||||
|
||||
await this.saveSessionState();
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
|
||||
this.authState = AuthenticationState.AUTHENTICATED;
|
||||
this.log('info', 'Login confirmed and session saved successfully');
|
||||
} else {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('warn', 'Login confirmation received but session state unclear');
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to confirm login', { error: message });
|
||||
return Result.err(error instanceof Error ? error : new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async clearSession(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Clearing session');
|
||||
|
||||
await this.cookieStore.delete();
|
||||
this.log('debug', 'Cookie store deleted');
|
||||
|
||||
const userDataDir = this.browserSession.getUserDataDir();
|
||||
if (userDataDir && fs.existsSync(userDataDir)) {
|
||||
this.log('debug', 'Removing user data directory', { path: userDataDir });
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
this.authState = AuthenticationState.LOGGED_OUT;
|
||||
this.log('info', 'Session cleared successfully');
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to clear session', { error: message });
|
||||
return Result.err(new Error(`Failed to clear session: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
getState(): AuthenticationState {
|
||||
return this.authState;
|
||||
}
|
||||
|
||||
async validateServerSide(): Promise<Result<boolean>> {
|
||||
try {
|
||||
this.log('info', 'Performing server-side session validation');
|
||||
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
return Result.err(new Error('No browser context available'));
|
||||
}
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const response = await page.goto(this.authFlow.getPostLoginLandingUrl(), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
const finalUrl = page.url();
|
||||
const isOnLoginPage = this.authFlow.isLoginUrl(finalUrl);
|
||||
|
||||
const isValid = !isOnLoginPage;
|
||||
this.log('info', 'Server-side validation complete', { isValid, finalUrl });
|
||||
|
||||
return Result.ok(isValid);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('warn', 'Server-side validation failed', { error: message });
|
||||
return Result.err(new Error(`Server validation failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Refreshing session from cookie store');
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state) {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
this.authState = this.cookieStore.validateCookies(state.cookies);
|
||||
this.log('info', 'Session refreshed', { state: this.authState });
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Session refresh failed', { error: message });
|
||||
return Result.err(new Error(`Session refresh failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionExpiry(): Promise<Result<Date | null>> {
|
||||
try {
|
||||
const expiry = await this.cookieStore.getSessionExpiry();
|
||||
return Result.ok(expiry);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to get session expiry', { error: message });
|
||||
return Result.err(new Error(`Failed to get session expiry: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>> {
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
try {
|
||||
const url = page.url();
|
||||
|
||||
const isOnAuthenticatedPath = this.authFlow.isAuthenticatedUrl(url);
|
||||
const isOnLoginPath = this.authFlow.isLoginUrl(url);
|
||||
|
||||
const guard = new AuthenticationGuard(page, this.logger);
|
||||
const hasLoginUI = await guard.checkForLoginUI();
|
||||
|
||||
const hasAuthUI = await this.authFlow.detectAuthenticatedUi(page);
|
||||
|
||||
const cookieResult = await this.checkSession();
|
||||
const cookiesValid =
|
||||
cookieResult.isOk() &&
|
||||
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
|
||||
|
||||
const pageAuthenticated =
|
||||
!hasLoginUI &&
|
||||
!isOnLoginPath &&
|
||||
((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
|
||||
|
||||
this.log('debug', 'Page authentication check', {
|
||||
url,
|
||||
isOnAuthenticatedPath,
|
||||
isOnLoginPath,
|
||||
hasLoginUI,
|
||||
hasAuthUI,
|
||||
cookiesValid,
|
||||
pageAuthenticated,
|
||||
});
|
||||
|
||||
return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(new Error(`Page verification failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Public helper for navigation with cookie injection =====
|
||||
|
||||
/**
|
||||
* Navigate to an authenticated area using stored cookies when possible.
|
||||
* Not part of the IAuthenticationService port, but useful for internal
|
||||
* orchestration (e.g. within automation flows).
|
||||
*/
|
||||
async navigateWithExistingSession(forceHeaded: boolean = false): Promise<Result<void>> {
|
||||
try {
|
||||
const sessionResult = await this.checkSession();
|
||||
if (
|
||||
sessionResult.isOk() &&
|
||||
sessionResult.unwrap() === AuthenticationState.AUTHENTICATED
|
||||
) {
|
||||
this.log('info', 'Session cookies found, launching in configured browser mode');
|
||||
|
||||
await this.browserSession.ensureBrowserContext(forceHeaded);
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
const targetUrl = this.authFlow.getPostLoginLandingUrl();
|
||||
const injectResult = await this.injectCookiesBeforeNavigation(targetUrl);
|
||||
|
||||
if (injectResult.isErr()) {
|
||||
this.log('warn', 'Cookie injection failed, falling back to manual login', {
|
||||
error: injectResult.error?.message ?? 'unknown error',
|
||||
});
|
||||
return Result.err(injectResult.unwrapErr());
|
||||
}
|
||||
|
||||
await page.goto(targetUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
const verifyResult = await this.verifyPageAuthentication();
|
||||
if (verifyResult.isOk()) {
|
||||
const browserState = verifyResult.unwrap();
|
||||
if (browserState.isFullyAuthenticated()) {
|
||||
this.log('info', 'Authentication verified successfully after cookie navigation');
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
this.log('warn', 'Page shows unauthenticated state despite cookies');
|
||||
}
|
||||
|
||||
return Result.err(new Error('Page not authenticated after cookie navigation'));
|
||||
}
|
||||
|
||||
return Result.err(new Error('No valid session cookies found'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to navigate with existing session', { error: message });
|
||||
return Result.err(new Error(`Failed to navigate with existing session: ${message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||
import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration';
|
||||
import { Result } from '../../../../../shared/result/Result';
|
||||
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
interface StorageState {
|
||||
cookies: Cookie[];
|
||||
origins: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Known iRacing session cookie names to look for.
|
||||
* These are the primary authentication indicators.
|
||||
*/
|
||||
const IRACING_SESSION_COOKIES = [
|
||||
'irsso_members',
|
||||
'authtoken_members',
|
||||
'irsso',
|
||||
'authtoken',
|
||||
];
|
||||
|
||||
/**
|
||||
* iRacing domain patterns to match cookies against.
|
||||
*/
|
||||
const IRACING_DOMAINS = [
|
||||
'iracing.com',
|
||||
'.iracing.com',
|
||||
'members.iracing.com',
|
||||
'members-ng.iracing.com',
|
||||
];
|
||||
|
||||
const EXPIRY_BUFFER_SECONDS = 300;
|
||||
|
||||
export class SessionCookieStore {
|
||||
private readonly storagePath: string;
|
||||
private readonly logger: LoggerPort | undefined;
|
||||
|
||||
constructor(userDataDir: string, logger: LoggerPort | undefined) {
|
||||
this.storagePath = path.join(userDataDir, 'session-state.json');
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (this.logger) {
|
||||
if (level === 'error') {
|
||||
this.logger.error(message, undefined, context);
|
||||
} else {
|
||||
this.logger[level](message, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
return this.storagePath;
|
||||
}
|
||||
|
||||
async read(): Promise<StorageState | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.storagePath, 'utf-8');
|
||||
const state = JSON.parse(content) as StorageState;
|
||||
|
||||
// Ensure all cookies have path field (default to "/" for backward compatibility)
|
||||
state.cookies = state.cookies.map(cookie => ({
|
||||
...cookie,
|
||||
path: cookie.path || '/'
|
||||
}));
|
||||
|
||||
this.cachedState = state;
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async write(state: StorageState): Promise<void> {
|
||||
this.cachedState = state;
|
||||
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.storagePath);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session expiry date from iRacing cookies.
|
||||
* Returns the earliest expiry date from valid session cookies.
|
||||
*/
|
||||
async getSessionExpiry(): Promise<Date | null> {
|
||||
try {
|
||||
const state = await this.read();
|
||||
if (!state || state.cookies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to iRacing authentication cookies
|
||||
const authCookies = state.cookies.filter(c =>
|
||||
IRACING_DOMAINS.some(domain =>
|
||||
c.domain === domain || c.domain.endsWith(domain)
|
||||
) &&
|
||||
(IRACING_SESSION_COOKIES.some(name =>
|
||||
c.name.toLowerCase().includes(name.toLowerCase())
|
||||
) ||
|
||||
c.name.toLowerCase().includes('auth') ||
|
||||
c.name.toLowerCase().includes('sso') ||
|
||||
c.name.toLowerCase().includes('token'))
|
||||
);
|
||||
|
||||
if (authCookies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the earliest expiry date (most restrictive)
|
||||
// Session cookies (expires = -1 or 0) are treated as never expiring
|
||||
const expiryDates = authCookies
|
||||
.filter(c => c.expires > 0)
|
||||
.map(c => {
|
||||
// Handle both formats: seconds (standard) and milliseconds (test fixtures)
|
||||
// If expires > year 2100 in seconds (33134745600), it's likely milliseconds
|
||||
const isMilliseconds = c.expires > 33134745600;
|
||||
return new Date(isMilliseconds ? c.expires : c.expires * 1000);
|
||||
});
|
||||
|
||||
if (expiryDates.length === 0) {
|
||||
// All session cookies, no expiry
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return earliest expiry
|
||||
const earliestExpiry = new Date(Math.min(...expiryDates.map(d => d.getTime())));
|
||||
|
||||
this.log('debug', 'Session expiry determined', {
|
||||
earliestExpiry: earliestExpiry.toISOString(),
|
||||
cookiesChecked: authCookies.length
|
||||
});
|
||||
|
||||
return earliestExpiry;
|
||||
} catch (error) {
|
||||
this.log('error', 'Failed to get session expiry', { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cookies and determine authentication state.
|
||||
*
|
||||
* Looks for iRacing session cookies by checking:
|
||||
* 1. Domain matches iRacing patterns
|
||||
* 2. Cookie name matches known session cookie names OR
|
||||
* 3. Any cookie from members.iracing.com domain (fallback)
|
||||
*/
|
||||
validateCookies(cookies: Cookie[]): AuthenticationState {
|
||||
// Log all cookies for debugging
|
||||
this.log('debug', 'Validating cookies', {
|
||||
totalCookies: cookies.length,
|
||||
cookieNames: cookies.map(c => ({ name: c.name, domain: c.domain }))
|
||||
});
|
||||
|
||||
// Filter cookies from iRacing domains
|
||||
const iracingDomainCookies = cookies.filter(c =>
|
||||
IRACING_DOMAINS.some(domain =>
|
||||
c.domain === domain || c.domain.endsWith(domain)
|
||||
)
|
||||
);
|
||||
|
||||
this.log('debug', 'iRacing domain cookies found', {
|
||||
count: iracingDomainCookies.length,
|
||||
cookies: iracingDomainCookies.map(c => ({
|
||||
name: c.name,
|
||||
domain: c.domain,
|
||||
expires: c.expires,
|
||||
expiresDate: new Date(c.expires * 1000).toISOString()
|
||||
}))
|
||||
});
|
||||
|
||||
// Look for known session cookies first
|
||||
const knownSessionCookies = iracingDomainCookies.filter(c =>
|
||||
IRACING_SESSION_COOKIES.some(name =>
|
||||
c.name.toLowerCase() === name.toLowerCase() ||
|
||||
c.name.toLowerCase().includes(name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// If no known session cookies, check for any auth-like cookies from members domain
|
||||
const authCookies = knownSessionCookies.length > 0
|
||||
? knownSessionCookies
|
||||
: iracingDomainCookies.filter(c =>
|
||||
c.domain.includes('members') &&
|
||||
(c.name.toLowerCase().includes('auth') ||
|
||||
c.name.toLowerCase().includes('sso') ||
|
||||
c.name.toLowerCase().includes('session') ||
|
||||
c.name.toLowerCase().includes('token'))
|
||||
);
|
||||
|
||||
this.log('debug', 'Authentication cookies identified', {
|
||||
knownSessionCookiesCount: knownSessionCookies.length,
|
||||
authCookiesCount: authCookies.length,
|
||||
cookies: authCookies.map(c => ({ name: c.name, domain: c.domain }))
|
||||
});
|
||||
|
||||
if (authCookies.length === 0) {
|
||||
// Last resort: if we have ANY cookies from members.iracing.com, consider it potentially valid
|
||||
const membersCookies = iracingDomainCookies.filter(c =>
|
||||
c.domain.includes('members.iracing.com') || c.domain === '.iracing.com'
|
||||
);
|
||||
|
||||
if (membersCookies.length > 0) {
|
||||
this.log('info', 'No known auth cookies found, but members domain cookies exist', {
|
||||
count: membersCookies.length,
|
||||
cookies: membersCookies.map(c => c.name)
|
||||
});
|
||||
|
||||
// Check expiry on any of these cookies
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hasValidCookie = membersCookies.some(c =>
|
||||
c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
|
||||
);
|
||||
|
||||
return hasValidCookie
|
||||
? AuthenticationState.AUTHENTICATED
|
||||
: AuthenticationState.EXPIRED;
|
||||
}
|
||||
|
||||
this.log('info', 'No iRacing authentication cookies found');
|
||||
return AuthenticationState.UNKNOWN;
|
||||
}
|
||||
|
||||
// Check if any auth cookie is still valid (not expired)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const validCookies = authCookies.filter(c => {
|
||||
// Handle session cookies (expires = -1 or 0) and persistent cookies
|
||||
const isSession = c.expires === -1 || c.expires === 0;
|
||||
const isNotExpired = c.expires > (now + EXPIRY_BUFFER_SECONDS);
|
||||
return isSession || isNotExpired;
|
||||
});
|
||||
|
||||
this.log('debug', 'Cookie expiry check', {
|
||||
now,
|
||||
validCookiesCount: validCookies.length,
|
||||
cookies: authCookies.map(c => ({
|
||||
name: c.name,
|
||||
expires: c.expires,
|
||||
isValid: c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
|
||||
}))
|
||||
});
|
||||
|
||||
if (validCookies.length > 0) {
|
||||
this.log('info', 'Valid iRacing session cookies found', { count: validCookies.length });
|
||||
return AuthenticationState.AUTHENTICATED;
|
||||
}
|
||||
|
||||
this.log('info', 'iRacing session cookies found but all expired');
|
||||
return AuthenticationState.EXPIRED;
|
||||
}
|
||||
|
||||
private cachedState: StorageState | null = null;
|
||||
|
||||
/**
|
||||
* Validate stored cookies for a target URL.
|
||||
* Note: This requires cookies to be written first via write().
|
||||
* This is synchronous because tests expect it - uses cached state.
|
||||
* Validates domain/path compatibility AND checks for required authentication cookies.
|
||||
*/
|
||||
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
|
||||
try {
|
||||
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
||||
return Result.err<Cookie[]>(new Error('No cookies found in session store'));
|
||||
}
|
||||
|
||||
return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a list of cookies for a target URL.
|
||||
* Returns only cookies that are valid for the target URL.
|
||||
* @param requireAuthCookies - If true, checks for required authentication cookies
|
||||
*/
|
||||
validateCookiesForUrl(
|
||||
cookies: Cookie[],
|
||||
targetUrl: string,
|
||||
requireAuthCookies = false
|
||||
): Result<Cookie[]> {
|
||||
try {
|
||||
const validatedCookies: Cookie[] = [];
|
||||
let firstValidationError: Error | null = null;
|
||||
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
new CookieConfiguration(cookie, targetUrl);
|
||||
validatedCookies.push(cookie);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (!firstValidationError) {
|
||||
firstValidationError = err;
|
||||
}
|
||||
|
||||
this.logger?.warn('Cookie validation failed', {
|
||||
name: cookie.name,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (validatedCookies.length === 0) {
|
||||
return Result.err<Cookie[]>(
|
||||
firstValidationError ?? new Error('No valid cookies found for target URL')
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuthCookies) {
|
||||
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
|
||||
|
||||
const hasIrssoMembers = cookieNames.some((name) =>
|
||||
name.includes('irsso_members') || name.includes('irsso')
|
||||
);
|
||||
|
||||
const hasAuthtokenMembers = cookieNames.some((name) =>
|
||||
name.includes('authtoken_members') || name.includes('authtoken')
|
||||
);
|
||||
|
||||
if (!hasIrssoMembers) {
|
||||
return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
|
||||
}
|
||||
|
||||
if (!hasAuthtokenMembers) {
|
||||
return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok<Cookie[]>(validatedCookies);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookies that are valid for a target URL.
|
||||
* Returns array of cookies (empty if none valid).
|
||||
* Uses cached state from last write().
|
||||
*/
|
||||
getValidCookiesForUrl(targetUrl: string): Cookie[] {
|
||||
try {
|
||||
if (!this.cachedState || this.cachedState.cookies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl);
|
||||
return result.isOk() ? result.unwrap() : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
import { Browser, BrowserContext, Page } from 'playwright';
|
||||
import { chromium } from 'playwright-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||
import { BrowserModeConfigLoader, BrowserMode } from '../../../config/BrowserModeConfig';
|
||||
import { getAutomationMode } from '../../../config/AutomationConfig';
|
||||
import type { PlaywrightConfig } from './PlaywrightAutomationAdapter';
|
||||
import { PlaywrightAutomationAdapter } from './PlaywrightAutomationAdapter';
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
export type BrowserModeSource = string;
|
||||
|
||||
export class PlaywrightBrowserSession {
|
||||
private browser: Browser | null = null;
|
||||
private persistentContext: BrowserContext | null = null;
|
||||
private context: BrowserContext | null = null;
|
||||
private page: Page | null = null;
|
||||
private connected = false;
|
||||
private isConnecting = false;
|
||||
private browserModeLoader: BrowserModeConfigLoader;
|
||||
private actualBrowserMode: BrowserMode;
|
||||
private browserModeSource: BrowserModeSource;
|
||||
|
||||
constructor(
|
||||
private readonly config: Required<PlaywrightConfig>,
|
||||
private readonly logger?: LoggerPort,
|
||||
browserModeLoader?: BrowserModeConfigLoader,
|
||||
) {
|
||||
const automationMode = getAutomationMode();
|
||||
this.browserModeLoader = browserModeLoader ?? new BrowserModeConfigLoader();
|
||||
const browserModeConfig = this.browserModeLoader.load();
|
||||
this.actualBrowserMode = browserModeConfig.mode;
|
||||
this.browserModeSource = browserModeConfig.source as BrowserModeSource;
|
||||
|
||||
this.log('info', 'Browser mode configured', {
|
||||
mode: this.actualBrowserMode,
|
||||
source: this.browserModeSource,
|
||||
automationMode,
|
||||
configHeadless: this.config.headless,
|
||||
});
|
||||
}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
private isRealMode(): boolean {
|
||||
return this.config.mode === 'real';
|
||||
}
|
||||
|
||||
getBrowserMode(): BrowserMode {
|
||||
return this.actualBrowserMode;
|
||||
}
|
||||
|
||||
getBrowserModeSource(): BrowserModeSource {
|
||||
return this.browserModeSource;
|
||||
}
|
||||
|
||||
getUserDataDir(): string {
|
||||
return this.config.userDataDir;
|
||||
}
|
||||
|
||||
getPage(): Page | null {
|
||||
return this.page;
|
||||
}
|
||||
|
||||
getContext(): BrowserContext | null {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
getPersistentContext(): BrowserContext | null {
|
||||
return this.persistentContext;
|
||||
}
|
||||
|
||||
getBrowser(): Browser | null {
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.page !== null;
|
||||
}
|
||||
|
||||
async connect(forceHeaded: boolean = false): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && this.page) {
|
||||
const shouldReuse =
|
||||
!forceHeaded ||
|
||||
this.actualBrowserMode === 'headed';
|
||||
|
||||
if (shouldReuse) {
|
||||
this.log('debug', 'Already connected, reusing existing connection', {
|
||||
browserMode: this.actualBrowserMode,
|
||||
forcedHeaded: forceHeaded,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
this.log('info', 'Existing browser connection is headless, reopening in headed mode for login', {
|
||||
browserMode: this.actualBrowserMode,
|
||||
forcedHeaded: forceHeaded,
|
||||
});
|
||||
await this.closeBrowserContext();
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
this.log('debug', 'Connection in progress, waiting...');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return this.connect(forceHeaded);
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
try {
|
||||
const currentConfig = this.browserModeLoader.load();
|
||||
this.actualBrowserMode = currentConfig.mode;
|
||||
this.browserModeSource = currentConfig.source as BrowserModeSource;
|
||||
const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode;
|
||||
|
||||
const adapterWithLauncher = PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
|
||||
testLauncher?: typeof chromium;
|
||||
};
|
||||
const launcher = adapterWithLauncher.testLauncher ?? chromium;
|
||||
|
||||
this.log('debug', 'Effective browser mode at connect', {
|
||||
effectiveMode,
|
||||
actualBrowserMode: this.actualBrowserMode,
|
||||
browserModeSource: this.browserModeSource,
|
||||
forced: forceHeaded,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
const loaderValue = this.browserModeLoader && typeof this.browserModeLoader.load === 'function'
|
||||
? this.browserModeLoader.load()
|
||||
: undefined;
|
||||
console.debug('[TEST-INSTRUMENT] PlaywrightAutomationAdapter.connect()', {
|
||||
effectiveMode,
|
||||
forceHeaded,
|
||||
loaderValue,
|
||||
browserModeSource: this.getBrowserModeSource(),
|
||||
});
|
||||
} catch {
|
||||
// ignore instrumentation errors
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRealMode() && this.config.userDataDir) {
|
||||
this.log('info', 'Launching persistent browser context', {
|
||||
userDataDir: this.config.userDataDir,
|
||||
mode: effectiveMode,
|
||||
forced: forceHeaded,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(this.config.userDataDir)) {
|
||||
fs.mkdirSync(this.config.userDataDir, { recursive: true });
|
||||
}
|
||||
|
||||
await this.cleanupStaleLockFile(this.config.userDataDir);
|
||||
|
||||
this.persistentContext = await launcher.launchPersistentContext(
|
||||
this.config.userDataDir,
|
||||
{
|
||||
headless: effectiveMode === 'headless',
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
);
|
||||
const persistentContext = this.persistentContext!;
|
||||
this.page = persistentContext.pages()[0] || await persistentContext.newPage();
|
||||
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
|
||||
this.connected = !!this.page;
|
||||
} else {
|
||||
this.browser = await launcher.launch({
|
||||
headless: effectiveMode === 'headless',
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
const browser = this.browser!;
|
||||
this.context = await browser.newContext();
|
||||
this.page = await this.context.newPage();
|
||||
this.page.setDefaultTimeout(this.config.timeout ?? 10000);
|
||||
this.connected = !!this.page;
|
||||
}
|
||||
|
||||
if (!this.page) {
|
||||
this.log('error', 'Browser session connected without a usable page', {
|
||||
hasBrowser: !!this.browser,
|
||||
hasContext: !!this.context || !!this.persistentContext,
|
||||
});
|
||||
await this.closeBrowserContext();
|
||||
this.connected = false;
|
||||
return { success: false, error: 'Browser not connected' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.connected = false;
|
||||
this.page = null;
|
||||
this.context = null;
|
||||
this.persistentContext = null;
|
||||
this.browser = null;
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureBrowserContext(forceHeaded: boolean = false): Promise<void> {
|
||||
const result = await this.connect(forceHeaded);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to connect browser');
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupStaleLockFile(userDataDir: string): Promise<void> {
|
||||
const singletonLockPath = path.join(userDataDir, 'SingletonLock');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(singletonLockPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('info', 'Found existing SingletonLock, attempting cleanup', { path: singletonLockPath });
|
||||
fs.unlinkSync(singletonLockPath);
|
||||
this.log('info', 'Cleaned up stale SingletonLock file');
|
||||
} catch (error) {
|
||||
this.log('warn', 'Could not clean up SingletonLock', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.page) {
|
||||
await this.page.close();
|
||||
this.page = null;
|
||||
}
|
||||
if (this.persistentContext) {
|
||||
await this.persistentContext.close();
|
||||
this.persistentContext = null;
|
||||
}
|
||||
if (this.context) {
|
||||
await this.context.close();
|
||||
this.context = null;
|
||||
}
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async closeBrowserContext(): Promise<void> {
|
||||
try {
|
||||
if (this.persistentContext) {
|
||||
await this.persistentContext.close();
|
||||
this.persistentContext = null;
|
||||
this.page = null;
|
||||
this.connected = false;
|
||||
this.log('info', 'Persistent context closed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.context) {
|
||||
await this.context.close();
|
||||
this.context = null;
|
||||
this.page = null;
|
||||
}
|
||||
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.log('info', 'Browser closed successfully');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('warn', 'Error closing browser context', { error: message });
|
||||
this.persistentContext = null;
|
||||
this.context = null;
|
||||
this.browser = null;
|
||||
this.page = null;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
|
||||
import type { NavigationResultDTO } from '../../../../application/dto/NavigationResultDTO';
|
||||
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
import { IRACING_SELECTORS, IRACING_TIMEOUTS, IRACING_URLS } from './IRacingSelectors';
|
||||
|
||||
export class IRacingDomNavigator {
|
||||
private static readonly STEP_TO_PAGE_MAP: Record<number, string> = {
|
||||
7: 'timeLimit',
|
||||
8: 'cars',
|
||||
9: 'cars',
|
||||
10: 'carClasses',
|
||||
11: 'track',
|
||||
12: 'track',
|
||||
13: 'trackOptions',
|
||||
14: 'timeOfDay',
|
||||
15: 'weather',
|
||||
16: 'raceOptions',
|
||||
17: 'trackConditions',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly config: Required<PlaywrightConfig>,
|
||||
private readonly browserSession: PlaywrightBrowserSession,
|
||||
private readonly logger?: LoggerPort,
|
||||
private readonly onWizardDismissed?: () => Promise<void>,
|
||||
) {}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
private isRealMode(): boolean {
|
||||
return this.config.mode === 'real';
|
||||
}
|
||||
|
||||
private getPage(): Page | null {
|
||||
return this.browserSession.getPage();
|
||||
}
|
||||
|
||||
async navigateToPage(url: string): Promise<NavigationResultDTO> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return { success: false, url, loadTime: 0, error: 'Browser not connected' };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const targetUrl = this.isRealMode() && !url.startsWith('http') ? IRACING_URLS.hostedSessions : url;
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.navigation : this.config.timeout;
|
||||
|
||||
this.log('debug', 'Navigating to page', { url: targetUrl, mode: this.config.mode });
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout });
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
if (!this.isRealMode()) {
|
||||
const stepMatch = url.match(/step-(\d+)-/);
|
||||
if (stepMatch) {
|
||||
const [, stepStr] = stepMatch;
|
||||
if (stepStr) {
|
||||
const stepNumber = parseInt(stepStr, 10);
|
||||
await page.evaluate((step) => {
|
||||
document.body.setAttribute('data-step', String(step));
|
||||
}, stepNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, url: targetUrl, loadTime };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const loadTime = Date.now() - startTime;
|
||||
return { success: false, url, loadTime, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElement(target: string, maxWaitMs?: number): Promise<WaitResultDTO> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return { success: false, target, waitedMs: 0, found: false, error: 'Browser not connected' };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const defaultTimeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
|
||||
try {
|
||||
let selector: string;
|
||||
if (target.startsWith('[') || target.startsWith('button') || target.startsWith('#')) {
|
||||
selector = target;
|
||||
} else {
|
||||
selector = IRACING_SELECTORS.wizard.modal;
|
||||
}
|
||||
|
||||
this.log('debug', 'Waiting for element', { target, selector, mode: this.config.mode });
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'attached',
|
||||
timeout: maxWaitMs ?? defaultTimeout,
|
||||
});
|
||||
return { success: true, target, waitedMs: Date.now() - startTime, found: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
target,
|
||||
waitedMs: Date.now() - startTime,
|
||||
found: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async waitForModal(): Promise<void> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Browser not connected');
|
||||
}
|
||||
const selector = IRACING_SELECTORS.wizard.modal;
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'attached',
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForStep(stepNumber: number): Promise<void> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Browser not connected');
|
||||
}
|
||||
|
||||
if (!this.isRealMode()) {
|
||||
await page.evaluate((step) => {
|
||||
document.body.setAttribute('data-step', String(step));
|
||||
}, stepNumber);
|
||||
}
|
||||
|
||||
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
|
||||
await page.waitForSelector(`[data-step="${stepNumber}"]`, {
|
||||
state: 'attached',
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForWizardStep(stepName: keyof typeof IRACING_SELECTORS.wizard.stepContainers): Promise<void> {
|
||||
const page = this.getPage();
|
||||
if (!page || !this.isRealMode()) return;
|
||||
|
||||
const containerSelector = IRACING_SELECTORS.wizard.stepContainers[stepName];
|
||||
if (!containerSelector) {
|
||||
this.log('warn', `Unknown wizard step: ${stepName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.log('debug', `Waiting for wizard step: ${stepName}`, { selector: containerSelector });
|
||||
await page.waitForSelector(containerSelector, {
|
||||
state: 'attached',
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
} catch (error) {
|
||||
this.log('warn', `Wizard step not attached: ${stepName}`, { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async detectCurrentWizardPage(): Promise<string | null> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const containers = IRACING_SELECTORS.wizard.stepContainers;
|
||||
|
||||
for (const [pageName, selector] of Object.entries(containers)) {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) {
|
||||
this.log('debug', 'Detected wizard page', { pageName, selector });
|
||||
return pageName;
|
||||
}
|
||||
}
|
||||
|
||||
this.log('debug', 'No wizard page detected');
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.log('debug', 'Error detecting wizard page', { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
synchronizeStepCounter(expectedStep: number, actualPage: string | null): number {
|
||||
if (!actualPage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let actualStep: number | null = null;
|
||||
for (const [step, pageName] of Object.entries(IRacingDomNavigator.STEP_TO_PAGE_MAP)) {
|
||||
if (pageName === actualPage) {
|
||||
actualStep = parseInt(step, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualStep === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const skipOffset = actualStep - expectedStep;
|
||||
|
||||
if (skipOffset > 0) {
|
||||
const skippedSteps: number[] = [];
|
||||
for (let i = expectedStep; i < actualStep; i++) {
|
||||
skippedSteps.push(i);
|
||||
}
|
||||
|
||||
this.log('warn', 'Wizard auto-skip detected', {
|
||||
expectedStep,
|
||||
actualStep,
|
||||
skipOffset,
|
||||
skippedSteps,
|
||||
});
|
||||
|
||||
return skipOffset;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async getCurrentStep(): Promise<number | null> {
|
||||
const page = this.getPage();
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isRealMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepAttr = await page.getAttribute('body', 'data-step');
|
||||
return stepAttr ? parseInt(stepAttr, 10) : null;
|
||||
}
|
||||
|
||||
private async isWizardModalDismissedInternal(): Promise<boolean> {
|
||||
const page = this.getPage();
|
||||
if (!page || !this.isRealMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stepContainerSelectors = Object.values(IRACING_SELECTORS.wizard.stepContainers);
|
||||
|
||||
for (const containerSelector of stepContainerSelectors) {
|
||||
const count = await page.locator(containerSelector).count();
|
||||
if (count > 0) {
|
||||
this.log('debug', 'Wizard step container attached, wizard is active', { containerSelector });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const modalSelector = '#create-race-modal, [role="dialog"], .modal.fade';
|
||||
const modalExists = (await page.locator(modalSelector).count()) > 0;
|
||||
|
||||
if (!modalExists) {
|
||||
this.log('debug', 'No wizard modal element found - dismissed');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.log('debug', 'Wizard step containers not attached, waiting 1000ms to confirm dismissal vs transition');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
for (const containerSelector of stepContainerSelectors) {
|
||||
const count = await page.locator(containerSelector).count();
|
||||
if (count > 0) {
|
||||
this.log('debug', 'Wizard step container attached after delay - was just transitioning', {
|
||||
containerSelector,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.log('info', 'No wizard step containers attached after delay - confirmed dismissed by user');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkWizardDismissed(currentStep: number): Promise<void> {
|
||||
if (!this.isRealMode() || currentStep < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isWizardModalDismissedInternal()) {
|
||||
this.log('info', 'Race creation wizard was dismissed by user');
|
||||
if (this.onWizardDismissed) {
|
||||
await this.onWizardDismissed().catch(() => {});
|
||||
}
|
||||
throw new Error('WIZARD_DISMISSED: User closed the race creation wizard');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Selectors for the real iRacing website (members.iracing.com)
|
||||
* Uses text-based and ARIA selectors since the site uses React/Chakra UI
|
||||
* with dynamically generated class names.
|
||||
*
|
||||
* VERIFIED against html-dumps-optimized 2025-11-27
|
||||
*/
|
||||
export const IRACING_SELECTORS = {
|
||||
// Login page
|
||||
login: {
|
||||
emailInput: '#username, input[name="username"], input[type="email"]',
|
||||
passwordInput: '#password, input[type="password"]',
|
||||
submitButton: 'button[type="submit"], button:has-text("Sign In")',
|
||||
},
|
||||
|
||||
// Hosted Racing page (Step 1/2)
|
||||
hostedRacing: {
|
||||
createRaceButton:
|
||||
'button:has-text("Create a Race"), button[aria-label="Create a Race"], button.chakra-button:has-text("Create a Race")',
|
||||
hostedTab: 'a:has-text("Hosted")',
|
||||
createRaceModal:
|
||||
'#confirm-create-race-modal-modal-content, ' +
|
||||
'#create-race-modal-modal-content, ' +
|
||||
'#confirm-create-race-modal, ' +
|
||||
'#create-race-modal, ' +
|
||||
'#modal-children-container, ' +
|
||||
'.modal-content',
|
||||
newRaceButton:
|
||||
'#confirm-create-race-modal-modal-content a.btn.btn-lg:has-text("New Race"), ' +
|
||||
'#create-race-modal-modal-content a.btn.btn-lg:has-text("New Race"), ' +
|
||||
'a.btn.btn-lg:has-text("New Race"), ' +
|
||||
'a.btn.btn-info:has-text("New Race"), ' +
|
||||
'.dropdown-menu a.dropdown-item.text-danger:has-text("New Race"), ' +
|
||||
'.dropdown-menu a.dropdown-item:has-text("New Race"), ' +
|
||||
'button.chakra-button:has-text("New Race")',
|
||||
lastSettingsButton:
|
||||
'#confirm-create-race-modal-modal-content a.btn.btn-lg:has-text("Last Settings"), ' +
|
||||
'#create-race-modal-modal-content a.btn.btn-lg:has-text("Last Settings"), ' +
|
||||
'a.btn.btn-lg:has-text("Last Settings"), ' +
|
||||
'a.btn.btn-info:has-text("Last Settings")',
|
||||
},
|
||||
|
||||
// Common modal/wizard selectors - VERIFIED from real HTML
|
||||
wizard: {
|
||||
modal: '#create-race-modal, .modal.fade.in',
|
||||
modalDialog: '#create-race-modal-modal-dialog, .modal-dialog',
|
||||
modalContent: '#create-race-modal-modal-content, .modal-content',
|
||||
modalTitle: '[data-testid="modal-title"]',
|
||||
// Wizard footer buttons (fixture + live)
|
||||
// Primary navigation uses sidebar; footer has Back/Next-style step links.
|
||||
nextButton:
|
||||
'.wizard-footer .btn-group.pull-xs-left a.btn.btn-sm:last-child, ' +
|
||||
'.wizard-footer .btn-group a.btn.btn-sm:last-child, ' +
|
||||
'.modal-footer .btn-toolbar a.btn:not(.dropdown-toggle), ' +
|
||||
'.modal-footer .btn-group a.btn:last-child',
|
||||
backButton:
|
||||
'.wizard-footer .btn-group.pull-xs-left a.btn.btn-sm:first-child, ' +
|
||||
'.wizard-footer .btn-group a.btn.btn-sm:first-child, ' +
|
||||
'.modal-footer .btn-group a.btn:first-child',
|
||||
// Modal footer actions
|
||||
confirmButton: '.modal-footer a.btn-success, .modal-footer button:has-text("Confirm"), button:has-text("OK")',
|
||||
cancelButton: '.modal-footer a.btn-secondary, button:has-text("Cancel")',
|
||||
closeButton: '[data-testid="button-close-modal"]',
|
||||
// Wizard sidebar navigation links (use real sidebar IDs so text is present)
|
||||
sidebarLinks: {
|
||||
raceInformation: '#wizard-sidebar-link-set-session-information',
|
||||
serverDetails: '#wizard-sidebar-link-set-server-details',
|
||||
admins: '#wizard-sidebar-link-set-admins',
|
||||
timeLimit: '#wizard-sidebar-link-set-time-limit',
|
||||
cars: '#wizard-sidebar-link-set-cars',
|
||||
track: '#wizard-sidebar-link-set-track',
|
||||
trackOptions: '#wizard-sidebar-link-set-track-options',
|
||||
timeOfDay: '#wizard-sidebar-link-set-time-of-day',
|
||||
weather: '#wizard-sidebar-link-set-weather',
|
||||
raceOptions: '#wizard-sidebar-link-set-race-options',
|
||||
trackConditions: '#wizard-sidebar-link-set-track-conditions',
|
||||
},
|
||||
// Wizard step containers (the visible step content)
|
||||
stepContainers: {
|
||||
raceInformation: '#set-session-information',
|
||||
serverDetails: '#set-server-details',
|
||||
admins: '#set-admins',
|
||||
timeLimit: '#set-time-limit',
|
||||
cars: '#set-cars',
|
||||
track: '#set-track',
|
||||
trackOptions: '#set-track-options',
|
||||
timeOfDay: '#set-time-of-day',
|
||||
weather: '#set-weather',
|
||||
raceOptions: '#set-race-options',
|
||||
trackConditions: '#set-track-conditions',
|
||||
},
|
||||
},
|
||||
|
||||
// Form fields - based on actual iRacing DOM structure
|
||||
fields: {
|
||||
textInput: '.chakra-input, input.form-control, input[type="text"], input[data-field], input[data-test], input[placeholder]',
|
||||
passwordInput: 'input[type="password"], input[maxlength="32"].form-control, input[data-field="password"], input[name="password"]',
|
||||
textarea: 'textarea.form-control, .chakra-textarea, textarea, textarea[data-field]',
|
||||
select: '.chakra-select, select.form-control, select, [data-dropdown], select[data-field]',
|
||||
checkbox: '.chakra-checkbox, input[type="checkbox"], .switch-checkbox, input[data-toggle], [data-toggle]',
|
||||
slider: '.chakra-slider, .slider, input[type="range"]',
|
||||
toggle: '.switch input.switch-checkbox, .toggle-switch input, input[data-toggle]',
|
||||
},
|
||||
|
||||
// Step-specific selectors - VERIFIED from real iRacing HTML structure
|
||||
steps: {
|
||||
// Step 3: Race Information - CORRECTED based on actual HTML structure
|
||||
// Session name is a text input in a form-group with label "Session Name"
|
||||
sessionName: '#set-session-information input.form-control[type="text"]:not([maxlength])',
|
||||
sessionNameAlt: 'input[name="sessionName"], input.form-control[type="text"]',
|
||||
// Password field has maxlength="32" and is a text input (not type="password")
|
||||
password: '#set-session-information input.form-control[maxlength="32"]',
|
||||
passwordAlt: 'input[maxlength="32"][type="text"]',
|
||||
// Description is a textarea in the form
|
||||
description: '#set-session-information textarea.form-control',
|
||||
descriptionAlt: 'textarea.form-control',
|
||||
// League racing toggle in Step 3
|
||||
leagueRacingToggle: '#set-session-information .switch-checkbox, [data-toggle="leagueRacing"]',
|
||||
|
||||
// Step 4: Server Details
|
||||
region:
|
||||
'#set-server-details select.form-control, ' +
|
||||
'#set-server-details [data-dropdown="region"], ' +
|
||||
'#set-server-details [data-dropdown], ' +
|
||||
'[data-dropdown="region"], ' +
|
||||
'#set-server-details [role="radiogroup"] input[type="radio"]',
|
||||
startNow:
|
||||
'#set-server-details .switch-checkbox, ' +
|
||||
'#set-server-details input[type="checkbox"], ' +
|
||||
'[data-toggle="startNow"], ' +
|
||||
'input[data-toggle="startNow"]',
|
||||
|
||||
// Step 5/6: Admins
|
||||
adminSearch: 'input[placeholder*="Search"]',
|
||||
adminList: '#set-admins table.table.table-striped, #set-admins .card-block table',
|
||||
addAdminButton: 'a.btn:has-text("Add an Admin")',
|
||||
|
||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with dynamic id
|
||||
// Fixtures show ids like time-limit-slider1764248520320
|
||||
practice: '#set-time-limit input[id*="time-limit-slider"]',
|
||||
qualify: '#set-time-limit input[id*="time-limit-slider"]',
|
||||
race: '#set-time-limit input[id*="time-limit-slider"]',
|
||||
|
||||
// Step 8/9: Cars
|
||||
carSearch:
|
||||
'#select-car-set-cars input[placeholder*="Search"], ' +
|
||||
'input[placeholder*="Search"]',
|
||||
carList: '#select-car-set-cars table.table.table-striped, table.table.table-striped',
|
||||
addCarButton:
|
||||
'#select-car-set-cars a.btn.btn-primary:has-text("Add a Car"), ' +
|
||||
'#select-car-set-cars a.btn.btn-primary:has-text("Add a Car 16 Available")',
|
||||
addCarModal:
|
||||
'#select-car-compact-content, ' +
|
||||
'.drawer-container, ' +
|
||||
'.drawer-container .drawer',
|
||||
carSelectButton:
|
||||
'#select-car-set-cars a.btn.btn-block:has-text("Select"), ' +
|
||||
'a.btn.btn-block:has-text("Select")',
|
||||
|
||||
// Step 10/11/12: Track
|
||||
trackSearch: 'input[placeholder*="Search"]',
|
||||
trackList: 'table.table.table-striped',
|
||||
// Add Track button - CORRECTED: Uses specific class and text
|
||||
addTrackButton: 'a.btn.btn-primary.btn-block.btn-sm:has-text("Add a Track")',
|
||||
// Track selection interface - drawer that opens within the card
|
||||
addTrackModal: '.drawer-container .drawer',
|
||||
// Select button inside track dropdown - opens config selection
|
||||
trackSelectButton: 'a.btn.btn-primary.btn-xs.dropdown-toggle:has-text("Select")',
|
||||
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
|
||||
trackSelectDropdown: 'a.btn.btn-primary.btn-xs.dropdown-toggle',
|
||||
// First item in the dropdown menu for selecting track configuration
|
||||
trackSelectDropdownItem: '.dropdown-menu.dropdown-menu-right .dropdown-item:first-child',
|
||||
|
||||
// Step 13: Track Options
|
||||
trackConfig: '#set-track-options select.form-control, #set-track-options [data-dropdown="trackConfig"]',
|
||||
|
||||
// Step 14: Time of Day - iRacing uses datetime picker (rdt class) and Bootstrap-slider components
|
||||
// The datetime picker has input.form-control, sliders have hidden input[type="text"]
|
||||
timeOfDay: '#set-time-of-day .rdt input.form-control, #set-time-of-day input[id*="slider"], #set-time-of-day .slider input[type="text"], #set-time-of-day [data-slider="timeOfDay"]',
|
||||
|
||||
// Step 15: Weather
|
||||
weatherType: '#set-weather select.form-control, #set-weather [data-dropdown="weatherType"]',
|
||||
// Temperature slider uses Bootstrap-slider with hidden input[type="text"]
|
||||
temperature: '#set-weather input[id*="slider"], #set-weather .slider input[type="text"], #set-weather [data-slider="temperature"]',
|
||||
|
||||
// Step 16: Race Options
|
||||
maxDrivers: '#set-race-options input[name*="maxDrivers"], #set-race-options input[type="number"]',
|
||||
rollingStart: '#set-race-options .switch-checkbox[name*="rolling"], #set-race-options input[type="checkbox"]',
|
||||
|
||||
// Step 17: Track Conditions (final step)
|
||||
trackState: '#set-track-conditions select.form-control, #set-track-conditions [data-dropdown="trackState"]',
|
||||
},
|
||||
|
||||
/**
|
||||
* DANGER ZONE - Selectors for checkout/payment buttons that should NEVER be clicked.
|
||||
* The automation must block any click on these selectors to prevent accidental purchases.
|
||||
* VERIFIED from real iRacing HTML - the checkout button has class btn-success with icon-cart
|
||||
*/
|
||||
BLOCKED_SELECTORS: {
|
||||
// Checkout/payment buttons - NEVER click these (verified from real HTML)
|
||||
checkout: '.chakra-button:has-text("Check Out"), a.btn-success:has(.icon-cart), a.btn:has-text("Check Out"), button:has-text("Check Out"), [data-testid*="checkout"]',
|
||||
purchase: 'button:has-text("Purchase"), a.btn:has-text("Purchase"), .chakra-button:has-text("Purchase"), button[aria-label="Purchase"]',
|
||||
buy: 'button:has-text("Buy"), a.btn:has-text("Buy Now"), button:has-text("Buy Now")',
|
||||
payment: 'button[type="submit"]:has-text("Submit Payment"), .payment-button, #checkout-button, button:has-text("Pay"), a.btn:has-text("Pay")',
|
||||
cart: 'a.btn:has(.icon-cart), button:has(.icon-cart), .btn-success:has(.icon-cart)',
|
||||
// Price labels that indicate purchase actions (e.g., "$0.50")
|
||||
priceAction: 'a.btn:has(.label-pill:has-text("$")), button:has(.label-pill:has-text("$")), .btn:has(.label-inverse:has-text("$"))',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Combined selector for all blocked/dangerous elements.
|
||||
* Use this to check if any selector targets a payment button.
|
||||
*/
|
||||
export const ALL_BLOCKED_SELECTORS = Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS).join(', ');
|
||||
|
||||
/**
|
||||
* Keywords that indicate a dangerous/checkout action.
|
||||
* Used for text-based safety checks.
|
||||
*/
|
||||
export const BLOCKED_KEYWORDS = [
|
||||
'checkout',
|
||||
'check out',
|
||||
'purchase',
|
||||
'buy now',
|
||||
'buy',
|
||||
'pay',
|
||||
'submit payment',
|
||||
'add to cart',
|
||||
'proceed to payment',
|
||||
] as const;
|
||||
|
||||
export const IRACING_URLS = {
|
||||
hostedSessions: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
login: 'https://members.iracing.com/membersite/login.jsp',
|
||||
home: 'https://members.iracing.com',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Timeout values for real iRacing automation (in milliseconds)
|
||||
*/
|
||||
export const IRACING_TIMEOUTS = {
|
||||
navigation: 30000,
|
||||
elementWait: 15000,
|
||||
loginWait: 120000, // 2 minutes for manual login
|
||||
pageLoad: 20000,
|
||||
} as const;
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||
import { IRACING_SELECTORS, BLOCKED_KEYWORDS } from './IRacingSelectors';
|
||||
import type { PlaywrightConfig } from '../core/PlaywrightAutomationAdapter';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
|
||||
export class SafeClickService {
|
||||
constructor(
|
||||
private readonly config: Required<PlaywrightConfig>,
|
||||
private readonly browserSession: PlaywrightBrowserSession,
|
||||
private readonly logger?: LoggerPort,
|
||||
) {}
|
||||
|
||||
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger as Record<
|
||||
'debug' | 'info' | 'warn' | 'error',
|
||||
(msg: string, ctx?: Record<string, unknown>) => void
|
||||
>;
|
||||
logger[level](message, context);
|
||||
}
|
||||
|
||||
private isRealMode(): boolean {
|
||||
return this.config.mode === 'real';
|
||||
}
|
||||
|
||||
private getPage(): Page {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) {
|
||||
throw new Error('Browser not connected');
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a selector or element text matches blocked patterns (checkout/payment buttons).
|
||||
* SAFETY CRITICAL: This prevents accidental purchases during automation.
|
||||
*
|
||||
* @param selector The CSS selector being clicked
|
||||
* @param elementText Optional text content of the element (should be direct text only)
|
||||
* @returns true if the selector/text matches a blocked pattern
|
||||
*/
|
||||
private isBlockedSelector(selector: string, elementText?: string): boolean {
|
||||
const selectorLower = selector.toLowerCase();
|
||||
const textLower = elementText?.toLowerCase().trim() ?? '';
|
||||
|
||||
// Check if selector contains any blocked keywords
|
||||
for (const keyword of BLOCKED_KEYWORDS) {
|
||||
if (selectorLower.includes(keyword) || textLower.includes(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for price indicators (e.g., "$0.50", "$19.99")
|
||||
// IMPORTANT: Only block if the price is combined with a checkout-related action word
|
||||
// This prevents false positives when price is merely displayed on the page
|
||||
const pricePattern = /\$\d+\.\d{2}/;
|
||||
const hasPrice = pricePattern.test(textLower) || pricePattern.test(selector);
|
||||
if (hasPrice) {
|
||||
// Only block if text also contains checkout-related words
|
||||
const checkoutActionWords = ['check', 'out', 'buy', 'purchase', 'pay', 'cart'];
|
||||
const hasCheckoutWord = checkoutActionWords.some(word => textLower.includes(word));
|
||||
if (hasCheckoutWord) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cart icon class
|
||||
if (selectorLower.includes('icon-cart') || selectorLower.includes('cart-icon')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an element is not a blocked checkout/payment button before clicking.
|
||||
* SAFETY CRITICAL: Throws error if element matches blocked patterns.
|
||||
*
|
||||
* This method checks:
|
||||
* 1. The selector string itself for blocked patterns
|
||||
* 2. The element's DIRECT text content (not children/siblings)
|
||||
* 3. The element's class, id, and href attributes for checkout indicators
|
||||
* 4. Whether the element matches any blocked CSS selectors
|
||||
*
|
||||
* @param selector The CSS selector of the element to verify
|
||||
* @throws Error if element is a blocked checkout/payment button
|
||||
*/
|
||||
async verifyNotBlockedElement(selector: string): Promise<void> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) return;
|
||||
|
||||
// In mock mode we bypass safety blocking to allow tests to exercise checkout flows
|
||||
// without risking real-world purchases. Safety checks remain active in 'real' mode.
|
||||
if (!this.isRealMode()) {
|
||||
this.log('debug', 'Mock mode detected - skipping checkout blocking checks', { selector });
|
||||
return;
|
||||
}
|
||||
|
||||
// First check the selector itself
|
||||
if (this.isBlockedSelector(selector)) {
|
||||
const errorMsg = `🚫 BLOCKED: Selector "${selector}" matches checkout/payment pattern. Automation stopped for safety.`;
|
||||
this.log('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Try to get the element's attributes and direct text for verification
|
||||
try {
|
||||
const element = page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
// Get element attributes for checking
|
||||
const elementClass = (await element.getAttribute('class').catch(() => '')) ?? '';
|
||||
const elementId = (await element.getAttribute('id').catch(() => '')) ?? '';
|
||||
const elementHref = (await element.getAttribute('href').catch(() => '')) ?? '';
|
||||
|
||||
// Check class/id/href for checkout indicators
|
||||
const attributeText = `${elementClass} ${elementId} ${elementHref}`.toLowerCase();
|
||||
if (
|
||||
attributeText.includes('checkout') ||
|
||||
attributeText.includes('cart') ||
|
||||
attributeText.includes('purchase') ||
|
||||
attributeText.includes('payment')
|
||||
) {
|
||||
const errorMsg = `🚫 BLOCKED: Element attributes contain checkout pattern. Class="${elementClass}", ID="${elementId}", Href="${elementHref}". Automation stopped for safety.`;
|
||||
this.log('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Get ONLY the direct text of this element, excluding child element text
|
||||
// This prevents false positives when a checkout button exists elsewhere on the page
|
||||
const directText = await element
|
||||
.evaluate((el) => {
|
||||
let text = '';
|
||||
const childNodes = Array.from(el.childNodes);
|
||||
for (const child of childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
text += child.textContent || '';
|
||||
}
|
||||
}
|
||||
return text.trim();
|
||||
})
|
||||
.catch(() => '');
|
||||
|
||||
// Also get innerText as fallback (for buttons with icon + text structure)
|
||||
// But only check if directText is empty or very short
|
||||
let textToCheck = directText;
|
||||
if (directText.length < 3) {
|
||||
const innerText = await element.innerText().catch(() => '');
|
||||
if (innerText.length < 100) {
|
||||
textToCheck = innerText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
this.log('debug', 'Checking element text for blocked patterns', {
|
||||
selector,
|
||||
directText,
|
||||
textToCheck,
|
||||
elementClass,
|
||||
});
|
||||
|
||||
if (textToCheck && this.isBlockedSelector('', textToCheck)) {
|
||||
const errorMsg = `🚫 BLOCKED: Element text "${textToCheck}" matches checkout/payment pattern. Automation stopped for safety.`;
|
||||
this.log('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Check if element matches any of the blocked selectors directly
|
||||
for (const blockedSelector of Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS)) {
|
||||
const matchesBlocked = await element
|
||||
.evaluate((el, sel) => {
|
||||
try {
|
||||
return el.matches(sel) || el.closest(sel) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, blockedSelector)
|
||||
.catch(() => false);
|
||||
|
||||
if (matchesBlocked) {
|
||||
const errorMsg = `🚫 BLOCKED: Element matches blocked selector "${blockedSelector}". Automation stopped for safety.`;
|
||||
this.log('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('BLOCKED')) {
|
||||
throw error;
|
||||
}
|
||||
this.log('debug', 'Could not verify element (may not exist yet)', { selector, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss any visible Chakra UI modal popups that might block interactions.
|
||||
* This handles various modal dismiss patterns including close buttons and overlay clicks.
|
||||
* Optimized for speed - uses instant visibility checks and minimal waits.
|
||||
*/
|
||||
async dismissModals(): Promise<void> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
const modalContainer = page.locator('.chakra-modal__content-container, .modal-content');
|
||||
const isModalVisible = await modalContainer.isVisible().catch(() => false);
|
||||
|
||||
if (!isModalVisible) {
|
||||
this.log('debug', 'No modal visible, continuing');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('info', 'Modal detected, dismissing immediately');
|
||||
|
||||
const dismissButton = page
|
||||
.locator(
|
||||
'.chakra-modal__content-container button[aria-label="Continue"], ' +
|
||||
'.chakra-modal__content-container button:has-text("Continue"), ' +
|
||||
'.chakra-modal__content-container button:has-text("Close"), ' +
|
||||
'.chakra-modal__content-container button:has-text("OK"), ' +
|
||||
'.chakra-modal__close-btn, ' +
|
||||
'[aria-label="Close"]',
|
||||
)
|
||||
.first();
|
||||
|
||||
if (await dismissButton.isVisible().catch(() => false)) {
|
||||
this.log('info', 'Clicking modal dismiss button');
|
||||
await dismissButton.click({ force: true, timeout: 1000 });
|
||||
await page.waitForTimeout(100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('debug', 'No dismiss button found, skipping Escape to avoid closing wizard');
|
||||
await page.waitForTimeout(100);
|
||||
} catch (error) {
|
||||
this.log('debug', 'Modal dismiss error (non-critical)', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss any open React DateTime pickers (rdt component).
|
||||
* These pickers can intercept pointer events and block clicks on other elements.
|
||||
* Used specifically before navigating away from steps that have datetime pickers.
|
||||
*
|
||||
* IMPORTANT: Do NOT use Escape key as it closes the entire wizard modal in iRacing.
|
||||
*/
|
||||
async dismissDatetimePickers(): Promise<void> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) return;
|
||||
|
||||
try {
|
||||
const initialCount = await page.locator('.rdt.rdtOpen').count();
|
||||
|
||||
if (initialCount === 0) {
|
||||
this.log('debug', 'No datetime picker open');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('info', `Closing ${initialCount} open datetime picker(s)`);
|
||||
|
||||
// Strategy 1: remove rdtOpen class via JS
|
||||
await page.evaluate(() => {
|
||||
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
|
||||
openPickers.forEach((picker) => {
|
||||
picker.classList.remove('rdtOpen');
|
||||
});
|
||||
const activeEl = document.activeElement as HTMLElement;
|
||||
if (activeEl && activeEl.blur && activeEl.closest('.rdt')) {
|
||||
activeEl.blur();
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
let stillOpenCount = await page.locator('.rdt.rdtOpen').count();
|
||||
if (stillOpenCount === 0) {
|
||||
this.log('debug', 'Datetime pickers closed via JavaScript');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: click outside
|
||||
this.log('debug', `${stillOpenCount} picker(s) still open, clicking outside`);
|
||||
const modalBody = page.locator(IRACING_SELECTORS.wizard.modalContent).first();
|
||||
if (await modalBody.isVisible().catch(() => false)) {
|
||||
const cardHeader = page.locator(`${IRACING_SELECTORS.wizard.stepContainers.timeOfDay} .card-header`).first();
|
||||
if (await cardHeader.isVisible().catch(() => false)) {
|
||||
await cardHeader.click({ force: true, timeout: 1000 }).catch(() => {});
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
stillOpenCount = await page.locator('.rdt.rdtOpen').count();
|
||||
if (stillOpenCount === 0) {
|
||||
this.log('debug', 'Datetime pickers closed via click outside');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: blur inputs and force-remove rdtOpen
|
||||
this.log('debug', `${stillOpenCount} picker(s) still open, force blur`);
|
||||
await page.evaluate(() => {
|
||||
const rdtInputs = document.querySelectorAll('.rdt input');
|
||||
rdtInputs.forEach((input) => {
|
||||
(input as HTMLElement).blur();
|
||||
});
|
||||
const openPickers = document.querySelectorAll('.rdt.rdtOpen');
|
||||
openPickers.forEach((picker) => {
|
||||
picker.classList.remove('rdtOpen');
|
||||
const pickerDropdown = picker.querySelector('.rdtPicker') as HTMLElement;
|
||||
if (pickerDropdown) {
|
||||
pickerDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const finalCount = await page.locator('.rdt.rdtOpen').count();
|
||||
if (finalCount > 0) {
|
||||
this.log('warn', `Could not close ${finalCount} datetime picker(s), will attempt click with force`);
|
||||
} else {
|
||||
this.log('debug', 'Datetime picker dismiss complete');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('debug', 'Datetime picker dismiss error (non-critical)', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe click wrapper that handles modal interception errors with auto-retry.
|
||||
* If a click fails because a modal is intercepting pointer events, this method
|
||||
* will dismiss the modal and retry the click operation.
|
||||
*
|
||||
* SAFETY: Before any click, verifies the target is not a checkout/payment button.
|
||||
*
|
||||
* @param selector The CSS selector of the element to click
|
||||
* @param options Click options including timeout and force
|
||||
* @returns Promise that resolves when click succeeds or throws after max retries
|
||||
*/
|
||||
async safeClick(
|
||||
selector: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
const page = this.getPage();
|
||||
|
||||
// In mock mode, ensure mock fixtures are visible (remove 'hidden' flags)
|
||||
if (!this.isRealMode()) {
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('.wizard-step.hidden, .modal.hidden, .wizard-step[hidden]')
|
||||
.forEach((el) => {
|
||||
el.classList.remove('hidden');
|
||||
el.removeAttribute('hidden');
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore any evaluation errors in test environments
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY CHECK: Verify this is not a checkout/payment button
|
||||
await this.verifyNotBlockedElement(selector);
|
||||
|
||||
const maxRetries = 3;
|
||||
const timeout = options?.timeout ?? this.config.timeout;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const useForce = options?.force || attempt === maxRetries;
|
||||
await page.click(selector, { timeout, force: useForce });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('BLOCKED')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const errorMessage = String(error);
|
||||
|
||||
if (
|
||||
errorMessage.includes('intercepts pointer events') ||
|
||||
errorMessage.includes('chakra-modal') ||
|
||||
errorMessage.includes('chakra-portal') ||
|
||||
errorMessage.includes('rdtDay') ||
|
||||
errorMessage.includes('rdtPicker') ||
|
||||
errorMessage.includes('rdt')
|
||||
) {
|
||||
this.log('info', `Element intercepting click (attempt ${attempt}/${maxRetries}), dismissing...`, {
|
||||
selector,
|
||||
attempt,
|
||||
maxRetries,
|
||||
});
|
||||
|
||||
await this.dismissDatetimePickers();
|
||||
await this.dismissModals();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
this.log('warn', 'Max retries reached, attempting JS click fallback', { selector });
|
||||
|
||||
try {
|
||||
const clicked = await page.evaluate((sel) => {
|
||||
try {
|
||||
const el = document.querySelector(sel) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
el.click();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, selector);
|
||||
|
||||
if (clicked) {
|
||||
this.log('info', 'JS fallback click succeeded', { selector });
|
||||
return;
|
||||
} else {
|
||||
this.log('debug', 'JS fallback click did not find element or failed', { selector });
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('debug', 'JS fallback click error', { selector, error: String(e) });
|
||||
}
|
||||
|
||||
this.log('error', 'Max retries reached, click still blocked', { selector });
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort';
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||
|
||||
type ValidationResult = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Real Automation Engine Adapter.
|
||||
*
|
||||
* Orchestrates the automation workflow by:
|
||||
* 1. Validating session configuration
|
||||
* 2. Executing each step using real browser automation
|
||||
* 3. Managing session state transitions
|
||||
*
|
||||
* This is a REAL implementation that uses actual automation,
|
||||
* not a mock. Historically delegated to legacy native screen
|
||||
* automation adapters, but those are no longer part of the
|
||||
* supported stack.
|
||||
*
|
||||
* @deprecated This adapter should be updated to use Playwright
|
||||
* browser automation when available. See docs/ARCHITECTURE.md
|
||||
* for the updated automation strategy.
|
||||
*/
|
||||
export class AutomationEngineAdapter implements AutomationEnginePort {
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: SessionRepositoryPort
|
||||
) {}
|
||||
|
||||
private toStepConfig(config: HostedSessionConfig): Record<string, unknown> {
|
||||
const baseConfig: Record<string, unknown> = {
|
||||
sessionName: config.sessionName,
|
||||
trackId: config.trackId,
|
||||
carIds: [...config.carIds],
|
||||
};
|
||||
|
||||
if (config.serverName !== undefined) baseConfig.serverName = config.serverName;
|
||||
if (config.password !== undefined) baseConfig.password = config.password;
|
||||
if (config.adminPassword !== undefined) baseConfig.adminPassword = config.adminPassword;
|
||||
if (config.maxDrivers !== undefined) baseConfig.maxDrivers = config.maxDrivers;
|
||||
if (config.carSearch !== undefined) baseConfig.carSearch = config.carSearch;
|
||||
if (config.trackSearch !== undefined) baseConfig.trackSearch = config.trackSearch;
|
||||
if (config.weatherType !== undefined) baseConfig.weatherType = config.weatherType;
|
||||
if (config.timeOfDay !== undefined) baseConfig.timeOfDay = config.timeOfDay;
|
||||
if (config.sessionDuration !== undefined) baseConfig.sessionDuration = config.sessionDuration;
|
||||
if (config.practiceLength !== undefined) baseConfig.practiceLength = config.practiceLength;
|
||||
if (config.qualifyingLength !== undefined) baseConfig.qualifyingLength = config.qualifyingLength;
|
||||
if (config.warmupLength !== undefined) baseConfig.warmupLength = config.warmupLength;
|
||||
if (config.raceLength !== undefined) baseConfig.raceLength = config.raceLength;
|
||||
if (config.startType !== undefined) baseConfig.startType = config.startType;
|
||||
if (config.restarts !== undefined) baseConfig.restarts = config.restarts;
|
||||
if (config.damageModel !== undefined) baseConfig.damageModel = config.damageModel;
|
||||
if (config.trackState !== undefined) baseConfig.trackState = config.trackState;
|
||||
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
return { isValid: false, error: 'Session name is required' };
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
return { isValid: false, error: 'Track ID is required' };
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
return { isValid: false, error: 'At least one car must be selected' };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('No active session found');
|
||||
}
|
||||
|
||||
// Start session if it's at step 1 and pending
|
||||
if (session.state.isPending() && stepId.value === 1) {
|
||||
session.start();
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// Start automated progression
|
||||
this.startAutomation(config);
|
||||
}
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = session.currentStep;
|
||||
|
||||
// Execute current step using the browser automation
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(currentStep, this.toStepConfig(config));
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
|
||||
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback for adapters without executeStep
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(nextStep, this.toStepConfig(config));
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
|
||||
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface IFixtureServer {
|
||||
start(port?: number): Promise<{ url: string; port: number }>;
|
||||
stop(): Promise<void>;
|
||||
getFixtureUrl(stepNumber: number): string;
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step number to fixture file mapping.
|
||||
* Steps 1-18 map to the corresponding HTML fixture files.
|
||||
*/
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
1: '01-hosted-racing.html',
|
||||
2: '02-create-a-race.html',
|
||||
3: '03-race-information.html',
|
||||
4: '04-server-details.html',
|
||||
5: '05-set-admins.html',
|
||||
6: '06-add-an-admin.html',
|
||||
7: '07-time-limits.html',
|
||||
8: '08-set-cars.html',
|
||||
9: '09-add-a-car.html',
|
||||
10: '10-set-car-classes.html',
|
||||
11: '11-set-track.html',
|
||||
12: '12-add-a-track.html',
|
||||
13: '13-track-options.html',
|
||||
14: '14-time-of-day.html',
|
||||
15: '15-weather.html',
|
||||
16: '16-race-options.html',
|
||||
17: '17-team-driving.html',
|
||||
18: '18-track-conditions.html',
|
||||
};
|
||||
|
||||
export class FixtureServer implements IFixtureServer {
|
||||
private server: http.Server | null = null;
|
||||
private port: number = 3456;
|
||||
private fixturesPath: string;
|
||||
|
||||
constructor(fixturesPath?: string) {
|
||||
this.fixturesPath =
|
||||
fixturesPath ?? path.resolve(process.cwd(), 'html-dumps/iracing-hosted-sessions');
|
||||
}
|
||||
|
||||
async start(port: number = 3456): Promise<{ url: string; port: number }> {
|
||||
if (this.server) {
|
||||
return { url: `http://localhost:${this.port}`, port: this.port };
|
||||
}
|
||||
|
||||
this.port = port;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
this.server = null;
|
||||
this.start(port + 1).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
resolve({ url: `http://localhost:${this.port}`, port: this.port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getFixtureUrl(stepNumber: number): string {
|
||||
const fixture = STEP_TO_FIXTURE[stepNumber];
|
||||
if (!fixture) {
|
||||
return `http://localhost:${this.port}/`;
|
||||
}
|
||||
return `http://localhost:${this.port}/${fixture}`;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const urlPath = req.url || '/';
|
||||
|
||||
let fileName: string;
|
||||
if (urlPath === '/') {
|
||||
fileName = STEP_TO_FIXTURE[1] ?? '01-hosted-racing.html';
|
||||
} else {
|
||||
fileName = urlPath.replace(/^\//, '');
|
||||
|
||||
const legacyMatch = fileName.match(/^step-(\d+)-/);
|
||||
if (legacyMatch) {
|
||||
const stepNum = Number(legacyMatch[1]);
|
||||
const mapped = STEP_TO_FIXTURE[stepNum];
|
||||
if (mapped) {
|
||||
fileName = mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = path.join(this.fixturesPath, fileName);
|
||||
|
||||
// Security check - prevent directory traversal
|
||||
if (!filePath.startsWith(this.fixturesPath)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
if (errno === 'ENOENT') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fixture filename for a given step number.
|
||||
*/
|
||||
export function getFixtureForStep(stepNumber: number): string | undefined {
|
||||
return STEP_TO_FIXTURE[stepNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all step-to-fixture mappings.
|
||||
*/
|
||||
export function getAllStepFixtureMappings(): Record<number, string> {
|
||||
return { ...STEP_TO_FIXTURE };
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { AutomationEnginePort } from '../../../../application/ports/AutomationEnginePort';
|
||||
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
|
||||
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';
|
||||
|
||||
type ValidationResult = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class MockAutomationEngineAdapter implements AutomationEnginePort {
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: SessionRepositoryPort
|
||||
) {}
|
||||
|
||||
private toStepConfig(config: HostedSessionConfig): Record<string, unknown> {
|
||||
const baseConfig: Record<string, unknown> = {
|
||||
sessionName: config.sessionName,
|
||||
trackId: config.trackId,
|
||||
carIds: [...config.carIds],
|
||||
};
|
||||
|
||||
if (config.serverName !== undefined) baseConfig.serverName = config.serverName;
|
||||
if (config.password !== undefined) baseConfig.password = config.password;
|
||||
if (config.adminPassword !== undefined) baseConfig.adminPassword = config.adminPassword;
|
||||
if (config.maxDrivers !== undefined) baseConfig.maxDrivers = config.maxDrivers;
|
||||
if (config.carSearch !== undefined) baseConfig.carSearch = config.carSearch;
|
||||
if (config.trackSearch !== undefined) baseConfig.trackSearch = config.trackSearch;
|
||||
if (config.weatherType !== undefined) baseConfig.weatherType = config.weatherType;
|
||||
if (config.timeOfDay !== undefined) baseConfig.timeOfDay = config.timeOfDay;
|
||||
if (config.sessionDuration !== undefined) baseConfig.sessionDuration = config.sessionDuration;
|
||||
if (config.practiceLength !== undefined) baseConfig.practiceLength = config.practiceLength;
|
||||
if (config.qualifyingLength !== undefined) baseConfig.qualifyingLength = config.qualifyingLength;
|
||||
if (config.warmupLength !== undefined) baseConfig.warmupLength = config.warmupLength;
|
||||
if (config.raceLength !== undefined) baseConfig.raceLength = config.raceLength;
|
||||
if (config.startType !== undefined) baseConfig.startType = config.startType;
|
||||
if (config.restarts !== undefined) baseConfig.restarts = config.restarts;
|
||||
if (config.damageModel !== undefined) baseConfig.damageModel = config.damageModel;
|
||||
if (config.trackState !== undefined) baseConfig.trackState = config.trackState;
|
||||
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
return { isValid: false, error: 'Session name is required' };
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
return { isValid: false, error: 'Track ID is required' };
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
return { isValid: false, error: 'At least one car must be selected' };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('No active session found');
|
||||
}
|
||||
|
||||
// Start session if it's at step 1 and pending
|
||||
if (session.state.isPending() && stepId.value === 1) {
|
||||
session.start();
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// Start automated progression
|
||||
this.startAutomation(config);
|
||||
}
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = session.currentStep;
|
||||
|
||||
// Execute current step using the browser automation
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(
|
||||
currentStep,
|
||||
this.toStepConfig(config),
|
||||
);
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(currentStep);
|
||||
const errorMessage = `Step ${currentStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback for adapters without executeStep (e.g., MockBrowserAutomationAdapter)
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(
|
||||
nextStep,
|
||||
this.toStepConfig(config),
|
||||
);
|
||||
if (!result.success) {
|
||||
const stepDescription = StepTransitionValidator.getStepDescription(nextStep);
|
||||
const errorMessage = `Step ${nextStep.value} (${stepDescription}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
this.isRunning = false;
|
||||
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (session && !session.state.isTerminal()) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
session.fail(`Automation error: ${message}`);
|
||||
await this.sessionRepository.update(session);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { StepId } from '../../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
|
||||
import type { NavigationResultDTO } from '../../../../application/dto/NavigationResultDTO';
|
||||
import type { FormFillResultDTO } from '../../../../application/dto/FormFillResultDTO';
|
||||
import type { ClickResultDTO } from '../../../../application/dto/ClickResultDTO';
|
||||
import type { WaitResultDTO } from '../../../../application/dto/WaitResultDTO';
|
||||
import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO';
|
||||
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
|
||||
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../IAutomationLifecycleEmitter';
|
||||
|
||||
interface MockConfig {
|
||||
simulateFailures?: boolean;
|
||||
failureRate?: number;
|
||||
}
|
||||
|
||||
interface StepExecutionResult {
|
||||
success: boolean;
|
||||
stepId: number;
|
||||
wasModalStep?: boolean;
|
||||
shouldStop?: boolean;
|
||||
executionTime: number;
|
||||
metrics: {
|
||||
totalDelay: number;
|
||||
operationCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MockBrowserAutomationAdapter implements IBrowserAutomation, IAutomationLifecycleEmitter {
|
||||
private config: MockConfig;
|
||||
private connected: boolean = false;
|
||||
private lifecycleCallbacks: Set<LifecycleCallback> = new Set();
|
||||
|
||||
constructor(config: MockConfig = {}) {
|
||||
this.config = {
|
||||
simulateFailures: config.simulateFailures ?? false,
|
||||
failureRate: config.failureRate ?? 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<AutomationResultDTO> {
|
||||
this.connected = true;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
async navigateToPage(url: string): Promise<NavigationResultDTO> {
|
||||
const delay = this.randomDelay(200, 800);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
loadTime: delay,
|
||||
};
|
||||
}
|
||||
|
||||
async fillFormField(fieldName: string, value: string): Promise<FormFillResultDTO> {
|
||||
const delay = this.randomDelay(100, 500);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
fieldName,
|
||||
valueSet: value,
|
||||
};
|
||||
}
|
||||
|
||||
async clickElement(selector: string): Promise<ClickResultDTO> {
|
||||
const delay = this.randomDelay(50, 300);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
target: selector,
|
||||
};
|
||||
}
|
||||
|
||||
async waitForElement(selector: string, maxWaitMs: number = 5000): Promise<WaitResultDTO> {
|
||||
const delay = this.randomDelay(100, 1000);
|
||||
|
||||
await this.sleep(delay);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
target: selector,
|
||||
waitedMs: delay,
|
||||
found: true,
|
||||
};
|
||||
}
|
||||
|
||||
async handleModal(stepId: StepId, action: string): Promise<ModalResultDTO> {
|
||||
if (!stepId.isModalStep()) {
|
||||
throw new Error(`Step ${stepId.value} is not a modal step`);
|
||||
}
|
||||
|
||||
const delay = this.randomDelay(200, 600);
|
||||
await this.sleep(delay);
|
||||
return {
|
||||
success: true,
|
||||
stepId: stepId.value,
|
||||
action,
|
||||
};
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResultDTO> {
|
||||
// Emit a simple lifecycle event for tests/overlay sync
|
||||
await this.emitLifecycle({
|
||||
type: 'action-started',
|
||||
actionId: String(stepId.value),
|
||||
timestamp: Date.now(),
|
||||
payload: { config },
|
||||
});
|
||||
if (this.shouldSimulateFailure()) {
|
||||
throw new Error(`Simulated failure at step ${stepId.value}`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let totalDelay = 0;
|
||||
let operationCount = 0;
|
||||
|
||||
const navigationDelay = this.randomDelay(200, 500);
|
||||
await this.sleep(navigationDelay);
|
||||
totalDelay += navigationDelay;
|
||||
operationCount++;
|
||||
|
||||
if (stepId.isModalStep()) {
|
||||
const modalDelay = this.randomDelay(200, 400);
|
||||
await this.sleep(modalDelay);
|
||||
totalDelay += modalDelay;
|
||||
operationCount++;
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata: {
|
||||
stepId: stepId.value,
|
||||
wasModalStep: stepId.isModalStep(),
|
||||
shouldStop: stepId.isFinalStep(),
|
||||
executionTime,
|
||||
totalDelay,
|
||||
operationCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private randomDelay(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private shouldSimulateFailure(): boolean {
|
||||
if (!this.config.simulateFailures) {
|
||||
return false;
|
||||
}
|
||||
return Math.random() < (this.config.failureRate || 0.1);
|
||||
}
|
||||
|
||||
onLifecycle(cb: LifecycleCallback): void {
|
||||
this.lifecycleCallbacks.add(cb);
|
||||
}
|
||||
|
||||
offLifecycle(cb: LifecycleCallback): void {
|
||||
this.lifecycleCallbacks.delete(cb);
|
||||
}
|
||||
|
||||
private async emitLifecycle(event: Parameters<LifecycleCallback>[0]): Promise<void> {
|
||||
for (const cb of Array.from(this.lifecycleCallbacks)) {
|
||||
await cb(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
core/automation/infrastructure/adapters/automation/index.ts
Normal file
19
core/automation/infrastructure/adapters/automation/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Automation adapters for browser automation.
|
||||
*
|
||||
* Exports:
|
||||
* - MockBrowserAutomationAdapter: Mock adapter for testing
|
||||
* - PlaywrightAutomationAdapter: Browser automation via Playwright
|
||||
* - FixtureServer: HTTP server for serving fixture HTML files
|
||||
*/
|
||||
|
||||
// Adapters
|
||||
export { MockBrowserAutomationAdapter } from './engine/MockBrowserAutomationAdapter';
|
||||
export { PlaywrightAutomationAdapter } from './core/PlaywrightAutomationAdapter';
|
||||
export type { PlaywrightConfig, AutomationAdapterMode } from './core/PlaywrightAutomationAdapter';
|
||||
|
||||
// Services
|
||||
export { FixtureServer, getFixtureForStep, getAllStepFixtureMappings } from './engine/FixtureServer';
|
||||
export type { IFixtureServer } from './engine/FixtureServer';
|
||||
|
||||
// Template map and utilities removed (image-based automation deprecated)
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* ElectronCheckoutConfirmationAdapter
|
||||
* Implements ICheckoutConfirmationPort using Electron IPC for main-renderer communication.
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
import { Result } from '../../../../shared/result/Result';
|
||||
import type { CheckoutConfirmationPort } from '../../../application/ports/CheckoutConfirmationPort';
|
||||
import type { CheckoutConfirmationRequestDTO } from '../../../application/dto/CheckoutConfirmationRequestDTO';
|
||||
import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
export class ElectronCheckoutConfirmationAdapter implements CheckoutConfirmationPort {
|
||||
private mainWindow: BrowserWindow;
|
||||
private pendingConfirmation: {
|
||||
resolve: (confirmation: CheckoutConfirmation) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
} | null = null;
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.setupIpcHandlers();
|
||||
}
|
||||
|
||||
private setupIpcHandlers(): void {
|
||||
// Listen for confirmation response from renderer
|
||||
ipcMain.on('checkout:confirm', (_event, decision: 'confirmed' | 'cancelled' | 'timeout') => {
|
||||
if (!this.pendingConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(this.pendingConfirmation.timeoutId);
|
||||
|
||||
// Create confirmation based on decision
|
||||
const confirmation = CheckoutConfirmation.create(decision);
|
||||
this.pendingConfirmation.resolve(confirmation);
|
||||
this.pendingConfirmation = null;
|
||||
});
|
||||
}
|
||||
|
||||
async requestCheckoutConfirmation(
|
||||
request: CheckoutConfirmationRequestDTO
|
||||
): Promise<Result<CheckoutConfirmation>> {
|
||||
try {
|
||||
// Only allow one pending confirmation at a time
|
||||
if (this.pendingConfirmation) {
|
||||
return Result.err(new Error('Confirmation already pending'));
|
||||
}
|
||||
|
||||
// Send request to renderer
|
||||
this.mainWindow.webContents.send('checkout:request-confirmation', {
|
||||
price: request.price.toDisplayString(),
|
||||
state: request.state.isReady() ? 'ready' : 'insufficient_funds',
|
||||
sessionMetadata: request.sessionMetadata,
|
||||
timeoutMs: request.timeoutMs,
|
||||
});
|
||||
|
||||
// Wait for response with timeout
|
||||
const confirmation = await new Promise<CheckoutConfirmation>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pendingConfirmation = null;
|
||||
const timeoutConfirmation = CheckoutConfirmation.create('timeout');
|
||||
resolve(timeoutConfirmation);
|
||||
}, request.timeoutMs);
|
||||
|
||||
this.pendingConfirmation = {
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
};
|
||||
});
|
||||
|
||||
return Result.ok(confirmation);
|
||||
} catch (error) {
|
||||
this.pendingConfirmation = null;
|
||||
return Result.err(
|
||||
error instanceof Error ? error : new Error('Failed to request confirmation')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup(): void {
|
||||
if (this.pendingConfirmation) {
|
||||
clearTimeout(this.pendingConfirmation.timeoutId);
|
||||
this.pendingConfirmation = null;
|
||||
}
|
||||
ipcMain.removeAllListeners('checkout:confirm');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { LoggerPort } from '../../../application/ports/LoggerPort';
|
||||
import { ConsoleLogger } from '../../../../shared/logging/ConsoleLogger';
|
||||
import { LogContext } from '../../../application/ports/LoggerContext';
|
||||
|
||||
export class ConsoleLogAdapter implements LoggerPort {
|
||||
private consoleLogger: ConsoleLogger;
|
||||
private readonly context: LogContext;
|
||||
|
||||
constructor(context: LogContext = {}) {
|
||||
this.consoleLogger = new ConsoleLogger();
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.consoleLogger.debug(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.consoleLogger.info(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.consoleLogger.warn(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: LogContext): void {
|
||||
this.consoleLogger.error(message, error, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
fatal(message: string, error?: Error, context?: LogContext): void {
|
||||
this.consoleLogger.error(`FATAL: ${message}`, error, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
child(context: LogContext = {}): LoggerPort {
|
||||
return new ConsoleLogAdapter({ ...this.context, ...context });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// No-op for console logger as it's synchronous
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LoggerPort } from '../../../application/ports/LoggerPort';
|
||||
import type { LogContext } from '../../../application/ports/LoggerContext';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class NoOpLogAdapter implements LoggerPort, ILogger {
|
||||
debug(_message: string, _context?: LogContext): void {}
|
||||
|
||||
info(_message: string, _context?: LogContext): void {}
|
||||
|
||||
warn(_message: string, _context?: LogContext): void {}
|
||||
|
||||
error(_message: string, _error?: Error, _context?: LogContext): void {}
|
||||
|
||||
fatal(_message: string, _error?: Error, _context?: LogContext): void {}
|
||||
|
||||
child(_context: LogContext): LoggerPort {
|
||||
return this;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||
import type { LogContext } from '@gridpilot/automation/application/ports/LoggerContext';
|
||||
import type { LogLevel } from '@gridpilot/automation/application/ports/LoggerLogLevel';
|
||||
import { loadLoggingConfig, type LoggingEnvironmentConfig } from '../../config/LoggingConfig';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
fatal: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* PinoLogAdapter - Electron-compatible logger implementation.
|
||||
*
|
||||
* Note: We use a custom console-based implementation instead of pino
|
||||
* because pino's internal use of diagnostics_channel.tracingChannel
|
||||
* is not compatible with Electron's Node.js version.
|
||||
*
|
||||
* This provides structured JSON logging to stdout with the same interface.
|
||||
*/
|
||||
export class PinoLogAdapter implements LoggerPort, ILogger {
|
||||
private readonly config: LoggingEnvironmentConfig;
|
||||
private readonly baseContext: LogContext;
|
||||
private readonly levelPriority: number;
|
||||
|
||||
constructor(config?: LoggingEnvironmentConfig, baseContext?: LogContext) {
|
||||
this.config = config || loadLoggingConfig();
|
||||
this.baseContext = {
|
||||
app: 'gridpilot-companion',
|
||||
version: process.env.npm_package_version || '0.0.0',
|
||||
processType: process.type || 'main',
|
||||
...baseContext,
|
||||
};
|
||||
this.levelPriority = LOG_LEVEL_PRIORITY[this.config.level];
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVEL_PRIORITY[level] >= this.levelPriority;
|
||||
}
|
||||
|
||||
private formatLog(level: LogLevel, message: string, context?: LogContext, error?: Error): string {
|
||||
const entry: Record<string, unknown> = {
|
||||
level,
|
||||
time: new Date().toISOString(),
|
||||
...this.baseContext,
|
||||
...context,
|
||||
msg: message,
|
||||
};
|
||||
|
||||
if (error) {
|
||||
entry.err = {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, context?: LogContext, error?: Error): void {
|
||||
if (!this.shouldLog(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const output = this.formatLog(level, message, context, error);
|
||||
|
||||
if (this.config.prettyPrint) {
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const levelColors: Record<LogLevel, string> = {
|
||||
debug: '\x1b[36m', // cyan
|
||||
info: '\x1b[32m', // green
|
||||
warn: '\x1b[33m', // yellow
|
||||
error: '\x1b[31m', // red
|
||||
fatal: '\x1b[35m', // magenta
|
||||
};
|
||||
const reset = '\x1b[0m';
|
||||
const color = levelColors[level];
|
||||
|
||||
const contextStr = context ? ` ${JSON.stringify(context)}` : '';
|
||||
const errorStr = error ? `\n ${error.stack || error.message}` : '';
|
||||
|
||||
console.log(`${color}[${timestamp}] ${level.toUpperCase()}${reset}: ${message}${contextStr}${errorStr}`);
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.log('debug', message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.log('info', message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.log('warn', message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: LogContext): void {
|
||||
this.log('error', message, context, error);
|
||||
}
|
||||
|
||||
fatal(message: string, error?: Error, context?: LogContext): void {
|
||||
this.log('fatal', message, context, error);
|
||||
}
|
||||
|
||||
child(context: LogContext): LoggerPort {
|
||||
return new PinoLogAdapter(this.config, { ...this.baseContext, ...context });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
// Console output is synchronous, nothing to flush
|
||||
}
|
||||
}
|
||||
2
core/automation/infrastructure/adapters/logging/index.ts
Normal file
2
core/automation/infrastructure/adapters/logging/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PinoLogAdapter } from './PinoLogAdapter';
|
||||
export { NoOpLogAdapter } from './NoOpLogAdapter';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user