wip
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { Page } from 'playwright';
|
||||
import { ILogger } from '../../../application/ports/ILogger';
|
||||
|
||||
export class AuthenticationGuard {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly logger?: ILogger
|
||||
) {}
|
||||
|
||||
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,102 @@
|
||||
import { Result } from '../../../shared/result/Result';
|
||||
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
|
||||
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
|
||||
import { CheckoutInfo } from '../../../application/ports/ICheckoutService';
|
||||
|
||||
interface Page {
|
||||
locator(selector: string): Locator;
|
||||
}
|
||||
|
||||
interface Locator {
|
||||
getAttribute(name: string): Promise<string | null>;
|
||||
innerHTML(): Promise<string>;
|
||||
textContent(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export class CheckoutPriceExtractor {
|
||||
private readonly selector = '.wizard-footer a.btn:has(span.label-pill)';
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async extractCheckoutInfo(): Promise<Result<CheckoutInfo>> {
|
||||
try {
|
||||
// Prefer the explicit pill element which contains the price
|
||||
const pillLocator = this.page.locator('span.label-pill');
|
||||
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').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: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface IFixtureServer {
|
||||
|
||||
/**
|
||||
* Step number to fixture file mapping.
|
||||
* Steps 2-18 map to the corresponding HTML fixture files.
|
||||
* Steps 2-17 map to the corresponding HTML fixture files.
|
||||
*/
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
2: 'step-02-hosted-racing.html',
|
||||
@@ -19,18 +19,17 @@ const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
4: 'step-04-race-information.html',
|
||||
5: 'step-05-server-details.html',
|
||||
6: 'step-06-set-admins.html',
|
||||
7: 'step-07-add-admin.html',
|
||||
8: 'step-08-time-limits.html',
|
||||
9: 'step-09-set-cars.html',
|
||||
10: 'step-10-add-car.html',
|
||||
11: 'step-11-set-car-classes.html',
|
||||
12: 'step-12-set-track.html',
|
||||
13: 'step-13-add-track.html',
|
||||
14: 'step-14-track-options.html',
|
||||
15: 'step-15-time-of-day.html',
|
||||
16: 'step-16-weather.html',
|
||||
17: 'step-17-race-options.html',
|
||||
18: 'step-18-track-conditions.html',
|
||||
7: 'step-07-time-limits.html', // Time Limits wizard step
|
||||
8: 'step-08-set-cars.html', // Set Cars wizard step
|
||||
9: 'step-09-add-car-modal.html', // Add Car modal
|
||||
10: 'step-10-set-car-classes.html', // Set Car Classes
|
||||
11: 'step-11-set-track.html', // Set Track wizard step (CORRECTED)
|
||||
12: 'step-12-add-track-modal.html', // Add Track modal
|
||||
13: 'step-13-track-options.html',
|
||||
14: 'step-14-time-of-day.html',
|
||||
15: 'step-15-weather.html',
|
||||
16: 'step-16-race-options.html',
|
||||
17: 'step-17-track-conditions.html',
|
||||
};
|
||||
|
||||
export class FixtureServer implements IFixtureServer {
|
||||
|
||||
@@ -111,26 +111,28 @@ export const IRACING_SELECTORS = {
|
||||
// Step 8/9: Cars
|
||||
carSearch: '.wizard-sidebar input[placeholder*="Search"], #set-cars input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||
carList: '#set-cars [data-list="cars"]',
|
||||
// Add Car button - triggers the Add Car modal
|
||||
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
|
||||
// Add Car modal - appears after clicking Add Car button
|
||||
addCarModal: '#add-car-modal, .modal:has(input[placeholder*="Search"]):has-text("Car")',
|
||||
// Select button inside Add Car modal table row - clicking this adds the car immediately (no confirm step)
|
||||
// Add Car button - triggers car selection interface in wizard sidebar
|
||||
// CORRECTED: Added fallback selectors since .icon-plus cannot be verified in minified HTML
|
||||
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars .card-header a.btn, #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
|
||||
// Car selection interface - CORRECTED: No separate modal, uses wizard sidebar within main modal
|
||||
addCarModal: '#create-race-modal .wizard-sidebar, #set-cars .wizard-sidebar, .wizard-sidebar:has(input[placeholder*="Search"])',
|
||||
// Select button inside car table row - clicking this adds the car immediately (no confirm step)
|
||||
// The "Select" button is an anchor styled as: a.btn.btn-block.btn-primary.btn-xs
|
||||
carSelectButton: '.modal table .btn-primary:has-text("Select"), .modal .btn-primary.btn-xs:has-text("Select"), .modal tbody .btn-primary',
|
||||
carSelectButton: '.wizard-sidebar table .btn-primary.btn-xs:has-text("Select"), #set-cars table .btn-primary.btn-xs:has-text("Select"), .modal table .btn-primary:has-text("Select")',
|
||||
|
||||
// Step 10/11/12: Track
|
||||
trackSearch: '.wizard-sidebar input[placeholder*="Search"], #set-track input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||
trackList: '#set-track [data-list="tracks"]',
|
||||
// Add Track button - triggers the Add Track modal
|
||||
addTrackButton: '#set-track a.btn:has(.icon-plus), #set-track button:has-text("Add"), #set-track a.btn:has-text("Add"), #set-track button:has-text("Select"), #set-track a.btn:has-text("Select")',
|
||||
// Add Track modal - appears after clicking Add Track button
|
||||
addTrackModal: '#add-track-modal, .modal:has(input[placeholder*="Search"]):has-text("Track")',
|
||||
// Select button inside Add Track modal table row - clicking this selects the track immediately (no confirm step)
|
||||
// Add Track button - triggers track selection interface in wizard sidebar
|
||||
// CORRECTED: Added fallback selectors since .icon-plus cannot be verified in minified HTML
|
||||
addTrackButton: '#set-track a.btn:has(.icon-plus), #set-track .card-header a.btn, #set-track button:has-text("Add"), #set-track a.btn:has-text("Add")',
|
||||
// Track selection interface - CORRECTED: No separate modal, uses wizard sidebar within main modal
|
||||
addTrackModal: '#create-race-modal .wizard-sidebar, #set-track .wizard-sidebar, .wizard-sidebar:has(input[placeholder*="Search"])',
|
||||
// Select button inside track table row - clicking this selects the track immediately (no confirm step)
|
||||
// Prefer direct buttons (not dropdown toggles) for single-config tracks
|
||||
trackSelectButton: '.modal table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)',
|
||||
trackSelectButton: '.wizard-sidebar table a.btn.btn-primary.btn-xs:not(.dropdown-toggle), #set-track table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)',
|
||||
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
|
||||
trackSelectDropdown: '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle',
|
||||
trackSelectDropdown: '.wizard-sidebar table a.btn.btn-primary.btn-xs.dropdown-toggle, #set-track table a.btn.btn-primary.btn-xs.dropdown-toggle',
|
||||
// First item in the dropdown menu for selecting track configuration
|
||||
trackSelectDropdownItem: '.dropdown-menu.show .dropdown-item:first-child, .dropdown-menu-lg .dropdown-item:first-child',
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
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 { ILogger } from '../../../application/ports/ILogger';
|
||||
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
@@ -33,6 +36,7 @@ const IRACING_DOMAINS = [
|
||||
'iracing.com',
|
||||
'.iracing.com',
|
||||
'members.iracing.com',
|
||||
'members-ng.iracing.com',
|
||||
];
|
||||
|
||||
const EXPIRY_BUFFER_SECONDS = 300;
|
||||
@@ -63,13 +67,23 @@ export class SessionCookieStore {
|
||||
async read(): Promise<StorageState | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.storagePath, 'utf-8');
|
||||
return JSON.parse(content) as StorageState;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -81,6 +95,65 @@ export class SessionCookieStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -192,4 +265,114 @@ export class SessionCookieStore {
|
||||
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('No cookies found in session store');
|
||||
}
|
||||
|
||||
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(`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 {
|
||||
// Validate each cookie's domain/path
|
||||
const validatedCookies: Cookie[] = [];
|
||||
let firstValidationError: string | null = null;
|
||||
|
||||
for (const cookie of cookies) {
|
||||
try {
|
||||
new CookieConfiguration(cookie, targetUrl);
|
||||
validatedCookies.push(cookie);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Capture first validation error to return if all cookies fail
|
||||
if (!firstValidationError) {
|
||||
firstValidationError = message;
|
||||
}
|
||||
|
||||
this.logger?.warn('Cookie validation failed', {
|
||||
name: cookie.name,
|
||||
error: message,
|
||||
});
|
||||
// Skip invalid cookie, continue with others
|
||||
}
|
||||
}
|
||||
|
||||
if (validatedCookies.length === 0) {
|
||||
// Return the specific validation error from the first failed cookie
|
||||
return Result.err(firstValidationError || 'No valid cookies found for target URL');
|
||||
}
|
||||
|
||||
// Check required cookies only if requested (for authentication validation)
|
||||
if (requireAuthCookies) {
|
||||
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
|
||||
|
||||
// Check for irsso_members
|
||||
const hasIrssoMembers = cookieNames.some((name) =>
|
||||
name.includes('irsso_members') || name.includes('irsso')
|
||||
);
|
||||
|
||||
// Check for authtoken_members
|
||||
const hasAuthtokenMembers = cookieNames.some((name) =>
|
||||
name.includes('authtoken_members') || name.includes('authtoken')
|
||||
);
|
||||
|
||||
if (!hasIrssoMembers) {
|
||||
return Result.err('Required cookie missing: irsso_members');
|
||||
}
|
||||
|
||||
if (!hasAuthtokenMembers) {
|
||||
return Result.err('Required cookie missing: authtoken_members');
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(validatedCookies);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(`Cookie validation failed: ${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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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 { ICheckoutConfirmationPort, CheckoutConfirmationRequest } from '../../../application/ports/ICheckoutConfirmationPort';
|
||||
import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation';
|
||||
|
||||
export class ElectronCheckoutConfirmationAdapter implements ICheckoutConfirmationPort {
|
||||
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: CheckoutConfirmationRequest
|
||||
): 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');
|
||||
}
|
||||
}
|
||||
59
packages/infrastructure/config/BrowserModeConfig.ts
Normal file
59
packages/infrastructure/config/BrowserModeConfig.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Browser mode configuration module for headed/headless browser toggle.
|
||||
*
|
||||
* Determines browser mode based on NODE_ENV:
|
||||
* - development: default headed, but configurable via runtime setter
|
||||
* - production: always headless
|
||||
* - test: always headless
|
||||
* - default: headless (for safety)
|
||||
*/
|
||||
|
||||
export type BrowserMode = 'headed' | 'headless';
|
||||
|
||||
export interface BrowserModeConfig {
|
||||
mode: BrowserMode;
|
||||
source: 'GUI' | 'NODE_ENV';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader for browser mode configuration.
|
||||
* Determines whether browser should run in headed or headless mode based on NODE_ENV.
|
||||
* In development mode, provides runtime control via setter method.
|
||||
*/
|
||||
export class BrowserModeConfigLoader {
|
||||
private developmentMode: BrowserMode = 'headed'; // Default to headed in development
|
||||
|
||||
/**
|
||||
* Load browser mode configuration based on NODE_ENV.
|
||||
* - NODE_ENV=development: returns current developmentMode (default: headed)
|
||||
* - NODE_ENV=production: always headless
|
||||
* - NODE_ENV=test: always headless
|
||||
* - default: headless (for safety)
|
||||
*/
|
||||
load(): BrowserModeConfig {
|
||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||
|
||||
if (nodeEnv === 'development') {
|
||||
return { mode: this.developmentMode, source: 'GUI' };
|
||||
}
|
||||
|
||||
return { mode: 'headless', source: 'NODE_ENV' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set browser mode for development environment.
|
||||
* Only affects behavior when NODE_ENV=development.
|
||||
* @param mode - The browser mode to use in development
|
||||
*/
|
||||
setDevelopmentMode(mode: BrowserMode): void {
|
||||
this.developmentMode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current development browser mode setting.
|
||||
* @returns The current browser mode for development
|
||||
*/
|
||||
getDevelopmentMode(): BrowserMode {
|
||||
return this.developmentMode;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Configuration module exports for infrastructure layer.
|
||||
* Infrastructure configuration barrel export.
|
||||
* Exports all configuration modules for easy imports.
|
||||
*/
|
||||
|
||||
export type { AutomationMode, AutomationEnvironmentConfig } from './AutomationConfig';
|
||||
export { loadAutomationConfig, getAutomationMode } from './AutomationConfig';
|
||||
export * from './AutomationConfig';
|
||||
export * from './LoggingConfig';
|
||||
export * from './BrowserModeConfig';
|
||||
Reference in New Issue
Block a user