This commit is contained in:
2025-12-12 14:23:40 +01:00
parent 6a88fe93ab
commit 2cd3bfbb47
58 changed files with 2866 additions and 260 deletions

View File

@@ -24,14 +24,16 @@ export class StartAutomationSessionUseCase
await this.sessionRepository.save(session);
return {
const dto: SessionDTO = {
sessionId: session.id,
state: session.state.value,
currentStep: session.currentStep.value,
config: session.config,
startedAt: session.startedAt,
completedAt: session.completedAt,
errorMessage: session.errorMessage,
...(session.startedAt ? { startedAt: session.startedAt } : {}),
...(session.completedAt ? { completedAt: session.completedAt } : {}),
...(session.errorMessage ? { errorMessage: session.errorMessage } : {}),
};
return dto;
}
}

View File

@@ -20,6 +20,18 @@ export class CheckoutConfirmation {
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;
}

View File

@@ -54,10 +54,6 @@ export class SessionState implements IValueObject<SessionStateProps> {
return this._value;
}
equals(other: SessionState): boolean {
return this._value === other._value;
}
isPending(): boolean {
return this._value === 'PENDING';
}

View File

@@ -25,10 +25,6 @@ export class StepId implements IValueObject<StepIdProps> {
return this._value;
}
equals(other: StepId): boolean {
return this._value === other._value;
}
isModalStep(): boolean {
return this._value === 6 || this._value === 9 || this._value === 12;
}

View File

@@ -27,10 +27,10 @@ interface PlaywrightAuthSessionConfig {
* - 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;
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;

View File

@@ -43,9 +43,9 @@ const EXPIRY_BUFFER_SECONDS = 300;
export class SessionCookieStore {
private readonly storagePath: string;
private logger?: LoggerPort;
private readonly logger: LoggerPort | undefined;
constructor(userDataDir: string, logger?: LoggerPort) {
constructor(userDataDir: string, logger: LoggerPort | undefined) {
this.storagePath = path.join(userDataDir, 'session-state.json');
this.logger = logger;
}

View File

@@ -428,7 +428,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
private browserSession: PlaywrightBrowserSession;
private connected = false;
private isConnecting = false;
private logger?: LoggerPort;
private logger: LoggerPort | undefined;
private cookieStore: SessionCookieStore;
private authService: PlaywrightAuthSessionService;
private overlayInjected = false;
@@ -438,7 +438,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
private static readonly PAUSE_CHECK_INTERVAL = 300;
/** Checkout confirmation callback - called before clicking checkout button */
private checkoutConfirmationCallback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>;
private checkoutConfirmationCallback: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation> =
async () => CheckoutConfirmation.cancelled('No checkout confirmation callback configured');
/** Page state validator instance */
private pageStateValidator: PageStateValidator;
@@ -448,7 +449,7 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
private domInteractor!: IRacingDomInteractor;
private readonly stepOrchestrator: WizardStepOrchestrator;
constructor(config: PlaywrightConfig = {}, logger?: LoggerPort, browserModeLoader?: BrowserModeConfigLoader) {
constructor(config: PlaywrightConfig = {}, logger: LoggerPort | undefined, browserModeLoader?: BrowserModeConfigLoader) {
this.config = {
headless: true,
timeout: 10000,
@@ -627,7 +628,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
async connect(forceHeaded: boolean = false): Promise<AutomationResultDTO> {
const result = await this.browserSession.connect(forceHeaded);
if (!result.success) {
return { success: false, error: result.error };
const errorMessage = result.error ?? 'Unknown automation connection error';
return { success: false, error: errorMessage };
}
this.syncSessionStateFromBrowser();
@@ -1333,12 +1335,10 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
// 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) => {
// Get only direct text nodes, not text from child elements
const directText = await element.evaluate((el: Node) => {
let text = '';
const childNodes = Array.from(el.childNodes);
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
const childNodes = Array.from((el as HTMLElement).childNodes);
for (const node of childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
}
@@ -2296,8 +2296,8 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
return { success: true, fieldName, valueSet: value };
} catch (evalErr) {
@@ -2495,11 +2495,11 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
// If element is a checkbox/input, set checked; otherwise try to toggle aria-checked or click
if ('checked' in el) {
(el as HTMLInputElement).checked = Boolean(should);
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
} else {
// Fallback: set aria-checked attribute and dispatch click
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
try { (el as HTMLElement).click(); } catch { /* ignore */ }
}
} catch {
@@ -3013,7 +3013,12 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
setCheckoutConfirmationCallback(
callback?: (price: CheckoutPrice, state: CheckoutState) => Promise<CheckoutConfirmation>
): void {
this.checkoutConfirmationCallback = callback;
if (callback) {
this.checkoutConfirmationCallback = callback;
} else {
this.checkoutConfirmationCallback = async () =>
CheckoutConfirmation.cancelled('No checkout confirmation callback configured');
}
}
// ===== Overlay Methods =====
@@ -3085,7 +3090,13 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
const progress = Math.round((step / this.totalSteps) * 100);
const personality = OVERLAY_PERSONALITY_MESSAGES[Math.floor(Math.random() * OVERLAY_PERSONALITY_MESSAGES.length)];
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }) => {
await this.page.evaluate(({ actionMsg, progressPct, stepNum, totalSteps, personalityMsg }: {
actionMsg: string;
progressPct: number;
stepNum: number;
totalSteps: number;
personalityMsg: string | undefined;
}) => {
const actionEl = document.getElementById('gridpilot-action');
const progressEl = document.getElementById('gridpilot-progress');
const stepTextEl = document.getElementById('gridpilot-step-text');
@@ -3094,9 +3105,9 @@ export class PlaywrightAutomationAdapter implements IBrowserAutomation, Authenti
if (actionEl) actionEl.textContent = actionMsg;
if (progressEl) progressEl.style.width = `${progressPct}%`;
if (stepTextEl) stepTextEl.textContent = actionMsg;
if (stepTextEl) stepTextEl.textContent = actionMsg ?? '';
if (stepCountEl) stepCountEl.textContent = `Step ${stepNum} of ${totalSteps}`;
if (personalityEl) personalityEl.textContent = personalityMsg;
if (personalityEl) personalityEl.textContent = personalityMsg ?? '';
}, {
actionMsg: actionMessage,
progressPct: progress,

View File

@@ -26,7 +26,7 @@ interface WizardStepOrchestratorDeps {
navigator: IRacingDomNavigator;
interactor: IRacingDomInteractor;
authService: AuthenticationServicePort;
logger?: LoggerPort;
logger?: LoggerPort | undefined;
totalSteps: number;
getCheckoutConfirmationCallback: () =>
| ((
@@ -68,7 +68,7 @@ export class WizardStepOrchestrator {
private readonly navigator: IRacingDomNavigator;
private readonly interactor: IRacingDomInteractor;
private readonly authService: AuthenticationServicePort;
private readonly logger?: LoggerPort;
private readonly logger: LoggerPort | undefined;
private readonly totalSteps: number;
private readonly getCheckoutConfirmationCallbackInternal: WizardStepOrchestratorDeps['getCheckoutConfirmationCallback'];
private readonly overlay: WizardStepOrchestratorDeps['overlay'];

View File

@@ -63,7 +63,7 @@ export class IRacingDomInteractor {
return { success: false, fieldName, valueSet: value, error: `Unknown form field: ${fieldName}` };
}
const selector = fieldMap[fieldName];
const selector = fieldMap[fieldName as keyof typeof fieldMap] ?? IRACING_SELECTORS.fields.textInput;
const timeout = this.isRealMode() ? IRACING_TIMEOUTS.elementWait : this.config.timeout;
this.log('debug', 'Filling form field', { fieldName, selector, mode: this.config.mode });
@@ -90,8 +90,8 @@ export class IRacingDomInteractor {
const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null;
if (!el) return;
el.value = val;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('input', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
return { success: true, fieldName, valueSet: value };
} catch (evalErr) {
@@ -514,10 +514,10 @@ export class IRacingDomInteractor {
try {
if ('checked' in el) {
(el as HTMLInputElement).checked = Boolean(should);
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
} else {
(el as HTMLElement).setAttribute('aria-checked', String(Boolean(should)));
el.dispatchEvent(new Event('change', { bubbles: true }));
(el as any).dispatchEvent(new Event('change', { bubbles: true }));
try {
(el as HTMLElement).click();
} catch {
@@ -621,8 +621,10 @@ export class IRacingDomInteractor {
try {
el.value = String(val);
el.setAttribute('data-value', String(val));
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
const inputEvent = new Event('input', { bubbles: true });
const changeEvent = new Event('change', { bubbles: true });
(el as any).dispatchEvent(inputEvent);
(el as any).dispatchEvent(changeEvent);
} catch {
// ignore
}

View File

@@ -67,10 +67,13 @@ export class IRacingDomNavigator {
if (!this.isRealMode()) {
const stepMatch = url.match(/step-(\d+)-/);
if (stepMatch) {
const stepNumber = parseInt(stepMatch[1], 10);
await page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
const [, stepStr] = stepMatch;
if (stepStr) {
const stepNumber = parseInt(stepStr, 10);
await page.evaluate((step) => {
document.body.setAttribute('data-step', String(step));
}, stepNumber);
}
}
}

View File

@@ -136,10 +136,9 @@ export class SafeClickService {
.evaluate((el) => {
let text = '';
const childNodes = Array.from(el.childNodes);
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || '';
for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent || '';
}
}
return text.trim();

View File

@@ -2,7 +2,6 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';

View File

@@ -105,7 +105,7 @@ export class FixtureServer implements IFixtureServer {
let fileName: string;
if (urlPath === '/') {
fileName = STEP_TO_FIXTURE[1];
fileName = STEP_TO_FIXTURE[1] ?? '01-hosted-racing.html';
} else {
fileName = urlPath.replace(/^\//, '');

View File

@@ -2,7 +2,6 @@ import type { AutomationEnginePort } from '../../../../application/ports/Automat
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import { StepId } from '../../../../domain/value-objects/StepId';
import type { IBrowserAutomation } from '../../../../application/ports/ScreenAutomationPort';
import type { HostedSessionConfig } from '../../../../domain/types/HostedSessionConfig';
import type { SessionRepositoryPort } from '../../../../application/ports/SessionRepositoryPort';
import { StepTransitionValidator } from '../../../../domain/services/StepTransitionValidator';

View File

@@ -6,7 +6,7 @@ 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';
import type { IAutomationLifecycleEmitter, LifecycleCallback } from '../../IAutomationLifecycleEmitter';
interface MockConfig {
simulateFailures?: boolean;