remove companion tests

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

View File

@@ -1,104 +0,0 @@
import "reflect-metadata";
import { container } from "tsyringe";
import { configureDIContainer, resetDIContainer } from "../../../apps/companion/main/di-config";
import { DI_TOKENS } from "../../../apps/companion/main/di-tokens";
import { OverlaySyncService } from "@gridpilot/automation/application/services/OverlaySyncService";
import { LoggerPort } from "@gridpilot/automation/application/ports/LoggerPort";
import { IAutomationLifecycleEmitter, LifecycleCallback } from "@gridpilot/automation/infrastructure//IAutomationLifecycleEmitter";
import { AutomationEventPublisherPort, AutomationEvent } from "@gridpilot/automation/application/ports/AutomationEventPublisherPort";
import { ConsoleLogAdapter } from "@gridpilot/automation/infrastructure//logging/ConsoleLogAdapter";
import { describe, it, expect, beforeEach, afterEach, vi, SpyInstance } from 'vitest';
describe("OverlaySyncService Integration with ConsoleLogAdapter", () => {
let consoleErrorSpy: SpyInstance<[message?: any, ...optionalParams: any[]], void>;
let consoleWarnSpy: SpyInstance<[message?: any, ...optionalParams: any[]], void>;
let originalNodeEnv: string | undefined;
beforeEach(() => {
originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
resetDIContainer();
configureDIContainer();
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
if (originalNodeEnv !== undefined) {
process.env.NODE_ENV = originalNodeEnv;
}
resetDIContainer();
});
it("should use ConsoleLogAdapter and log messages when OverlaySyncService encounters an error", async () => {
const logger = container.resolve<LoggerPort>(DI_TOKENS.Logger);
const overlaySyncService = container.resolve<OverlaySyncService>(DI_TOKENS.OverlaySyncPort);
expect(logger).toBeInstanceOf(ConsoleLogAdapter);
const mockLifecycleEmitter: IAutomationLifecycleEmitter = {
onLifecycle: vi.fn((_cb: LifecycleCallback) => {
throw new Error("Test lifecycle emitter error");
}),
offLifecycle: vi.fn(),
};
const mockPublisher: AutomationEventPublisherPort = {
publish: vi.fn(),
};
const serviceWithMockedEmitter = new OverlaySyncService({
lifecycleEmitter: mockLifecycleEmitter,
publisher: mockPublisher,
logger: logger,
});
const action = { id: "test-action-1", label: "Test Action" };
await serviceWithMockedEmitter.execute(action);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("OverlaySyncService: failed to subscribe to lifecycleEmitter"),
expect.any(Error),
expect.objectContaining({ actionId: action.id }),
);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it("should use ConsoleLogAdapter and log warn messages when OverlaySyncService fails to publish", async () => {
const logger = container.resolve<LoggerPort>(DI_TOKENS.Logger);
expect(logger).toBeInstanceOf(ConsoleLogAdapter);
const mockLifecycleEmitter: IAutomationLifecycleEmitter = {
onLifecycle: vi.fn(),
offLifecycle: vi.fn(),
};
const mockPublisher: AutomationEventPublisherPort = {
publish: vi.fn((_event: AutomationEvent) => {
throw new Error("Test publish error");
}),
};
const serviceWithMockedPublisher = new OverlaySyncService({
lifecycleEmitter: mockLifecycleEmitter,
publisher: mockPublisher,
logger: logger,
});
const action = { id: "test-action-2", label: "Test Action" };
await serviceWithMockedPublisher.execute(action);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("OverlaySyncService: publisher.publish failed"),
expect.objectContaining({
actionId: action.id,
error: expect.any(Error),
}),
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,106 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { DIContainer } from '../../../..//apps/companion/main/di-container';
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
import { PlaywrightAutomationAdapter } from '../../../../apps/companion/main/automation/infrastructure/automation';
describe('companion start automation - browser mode refresh wiring', () => {
const originalEnv = { ...process.env };
let originalTestLauncher: unknown;
beforeEach(() => {
process.env = { ...originalEnv, NODE_ENV: 'development' };
originalTestLauncher = (PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: unknown;
}).testLauncher;
const mockLauncher = {
launch: async (_opts: unknown) => ({
newContext: async () => ({
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
close: async () => {},
}),
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
close: async () => {},
}),
launchPersistentContext: async (_userDataDir: string, _opts: unknown) => ({
pages: () => [{ setDefaultTimeout: () => {}, close: async () => {} }],
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
close: async () => {},
}),
};
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: typeof mockLauncher;
}).testLauncher = mockLauncher;
DIContainer.resetInstance();
});
afterEach(async () => {
const container = DIContainer.getInstance();
await container.shutdown();
DIContainer.resetInstance();
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: unknown;
}).testLauncher = originalTestLauncher;
process.env = originalEnv;
});
it('uses refreshed browser automation for connection and step execution after mode change', async () => {
const container = DIContainer.getInstance();
const loader = container.getBrowserModeConfigLoader();
expect(loader.getDevelopmentMode()).toBe('headed');
const preStart = container.getStartAutomationUseCase();
const preEngine = container.getAutomationEngine();
const preAutomation = container.getBrowserAutomation();
expect(preAutomation).toBe(preEngine.browserAutomation);
loader.setDevelopmentMode('headless');
container.refreshBrowserAutomation();
const postStart = container.getStartAutomationUseCase();
const postEngine = container.getAutomationEngine();
const postAutomation = container.getBrowserAutomation();
expect(postAutomation).toBe(postEngine.browserAutomation);
expect(postAutomation).not.toBe(preAutomation);
expect(postStart).not.toBe(preStart);
const connectionResult = await container.initializeBrowserConnection();
expect(connectionResult.success).toBe(true);
const config: HostedSessionConfig = {
sessionName: 'Companion browser-mode refresh wiring',
trackId: 'test-track',
carIds: ['car-1'],
};
const dto = await postStart.execute(config);
await postEngine.executeStep(StepId.create(1), config);
const sessionRepository = container.getSessionRepository();
const session = await sessionRepository.findById(dto.sessionId);
expect(session).toBeDefined();
const state = session!.state.value as string;
const errorMessage = session!.errorMessage as string | undefined;
if (errorMessage) {
expect(errorMessage).not.toContain('Browser not connected');
}
const automationFromConnection = container.getBrowserAutomation();
const automationFromEngine = (container.getAutomationEngine() as { browserAutomation: unknown })
.browserAutomation;
expect(automationFromConnection).toBe(automationFromEngine);
expect(automationFromConnection).toBe(postAutomation);
});
});

View File

@@ -1,104 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { DIContainer } from '../../../..//apps/companion/main/di-container';
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
import { PlaywrightAutomationAdapter } from '../../../../apps/companion/main/automation/infrastructure/automation';
describe('companion start automation - browser not connected at step 1', () => {
const originalEnv = { ...process.env };
let originalTestLauncher: unknown;
beforeEach(() => {
process.env = { ...originalEnv, NODE_ENV: 'production' };
originalTestLauncher = (PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: unknown;
}).testLauncher;
const mockLauncher = {
launch: async (_opts: unknown) => ({
newContext: async () => ({
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
close: async () => {},
}),
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
close: async () => {},
}),
launchPersistentContext: async (_userDataDir: string, _opts: unknown) => ({
pages: () => [{ setDefaultTimeout: () => {}, close: async () => {} }],
newPage: async () => ({ setDefaultTimeout: () => {}, close: async () => {} }),
close: async () => {},
}),
};
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: typeof mockLauncher;
}).testLauncher = mockLauncher;
DIContainer.resetInstance();
});
afterEach(async () => {
const container = DIContainer.getInstance();
await container.shutdown();
DIContainer.resetInstance();
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: unknown;
}).testLauncher = originalTestLauncher;
process.env = originalEnv;
});
it('marks the session as FAILED with Step 1 (LOGIN) browser-not-connected error', async () => {
const container = DIContainer.getInstance();
const startAutomationUseCase = container.getStartAutomationUseCase();
const sessionRepository = container.getSessionRepository();
const automationEngine = container.getAutomationEngine();
const connectionResult = await container.initializeBrowserConnection();
expect(connectionResult.success).toBe(true);
const browserAutomation = container.getBrowserAutomation();
if (typeof (browserAutomation as { disconnect?: () => Promise<void> }).disconnect === 'function') {
await (browserAutomation as { disconnect: () => Promise<void> }).disconnect();
}
const config: HostedSessionConfig = {
sessionName: 'Companion integration browser-not-connected',
trackId: 'test-track',
carIds: ['car-1'],
};
const dto = await startAutomationUseCase.execute(config);
await automationEngine.executeStep(StepId.create(1), config);
const session = await waitForFailedSession(sessionRepository, dto.sessionId);
expect(session).toBeDefined();
expect(session!.state!.value).toBe('FAILED');
const error = session!.errorMessage as string | undefined;
expect(error).toBeDefined();
expect(error).toContain('Step 1 (Navigate to Hosted Racing page)');
expect(error).toContain('Browser not connected');
});
});
async function waitForFailedSession(
sessionRepository: { findById: (id: string) => Promise<{ state?: { value?: string }; errorMessage?: unknown } | null> },
sessionId: string,
timeoutMs = 5000,
): Promise<{ state?: { value?: string }; errorMessage?: unknown } | null> {
const start = Date.now();
let last: any = null;
// eslint-disable-next-line no-constant-condition
while (true) {
last = await sessionRepository.findById(sessionId);
if (last && last.state && last.state.value === 'FAILED') {
return last;
}
if (Date.now() - start >= timeoutMs) {
return last;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}

View File

@@ -1,113 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DIContainer } from '../../../..//apps/companion/main/di-container';
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
import { PlaywrightAutomationAdapter } from '../../../../apps/companion/main/automation/infrastructure/automation';
describe('companion start automation - browser connection failure before steps', () => {
const originalEnv = { ...process.env };
let originalTestLauncher: unknown;
beforeEach(() => {
process.env = { ...originalEnv, NODE_ENV: 'production' };
originalTestLauncher = (PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: unknown;
}).testLauncher;
const failingLauncher = {
launch: async () => {
throw new Error('Simulated browser launch failure');
},
launchPersistentContext: async () => {
throw new Error('Simulated persistent context failure');
},
};
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: typeof failingLauncher;
}).testLauncher = failingLauncher;
DIContainer.resetInstance();
});
afterEach(async () => {
const container = DIContainer.getInstance();
await container.shutdown();
DIContainer.resetInstance();
(PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
testLauncher?: unknown;
}).testLauncher = originalTestLauncher;
process.env = originalEnv;
});
it('fails browser connection and aborts before executing step 1', async () => {
const container = DIContainer.getInstance();
const startAutomationUseCase = container.getStartAutomationUseCase();
const sessionRepository = container.getSessionRepository();
const automationEngine = container.getAutomationEngine();
const connectionResult = await container.initializeBrowserConnection();
expect(connectionResult.success).toBe(false);
expect(connectionResult.error).toBeDefined();
const executeStepSpy = vi.spyOn(
automationEngine,
'executeStep',
);
const config: HostedSessionConfig = {
sessionName: 'Companion integration connection failure',
trackId: 'test-track',
carIds: ['car-1'],
};
let sessionId: string | null = null;
try {
const dto = await startAutomationUseCase.execute(config);
sessionId = dto.sessionId;
} catch (error) {
expect((error as Error).message).toBeDefined();
}
expect(executeStepSpy).not.toHaveBeenCalled();
if (sessionId) {
const session = await sessionRepository.findById(sessionId);
if (session) {
const message = session.errorMessage as string | undefined;
if (message) {
expect(message).not.toContain('Step 1 (LOGIN) failed: Browser not connected');
expect(message.toLowerCase()).toContain('browser');
}
}
}
});
it('treats successful adapter connect without a page as connection failure', async () => {
const container = DIContainer.getInstance();
const browserAutomation = container.getBrowserAutomation();
expect(browserAutomation).toBeInstanceOf(PlaywrightAutomationAdapter);
const AdapterWithPrototype = PlaywrightAutomationAdapter as typeof PlaywrightAutomationAdapter & {
prototype: {
connect: () => Promise<{ success: boolean; error?: string }>;
};
};
const originalConnect = AdapterWithPrototype.prototype.connect;
AdapterWithPrototype.prototype.connect = async function () {
return { success: true };
};
try {
const connectionResult = await container.initializeBrowserConnection();
expect(connectionResult.success).toBe(false);
expect(connectionResult.error).toBeDefined();
expect(String(connectionResult.error).toLowerCase()).toContain('browser');
} finally {
AdapterWithPrototype.prototype.connect = originalConnect;
}
});
});

View File

@@ -1,54 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { DIContainer } from '../../../..//apps/companion/main/di-container';
import type { HostedSessionConfig } from 'apps/companion/main/automation/domain/types/HostedSessionConfig';
import { StepId } from 'apps/companion/main/automation/domain/value-objects/StepId';
describe('companion start automation - happy path', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv, NODE_ENV: 'test' };
DIContainer.resetInstance();
});
afterEach(async () => {
const container = DIContainer.getInstance();
await container.shutdown();
DIContainer.resetInstance();
process.env = originalEnv;
});
it('creates a non-failed session and does not report browser-not-connected', async () => {
const container = DIContainer.getInstance();
const startAutomationUseCase = container.getStartAutomationUseCase();
const sessionRepository = container.getSessionRepository();
const automationEngine = container.getAutomationEngine();
const connectionResult = await container.initializeBrowserConnection();
expect(connectionResult.success).toBe(true);
const config: HostedSessionConfig = {
sessionName: 'Companion integration happy path',
trackId: 'test-track',
carIds: ['car-1'],
};
const dto = await startAutomationUseCase.execute(config);
const sessionBefore = await sessionRepository.findById(dto.sessionId);
expect(sessionBefore).toBeDefined();
await automationEngine.executeStep(StepId.create(1), config);
const session = await sessionRepository.findById(dto.sessionId);
expect(session).toBeDefined();
const state = session!.state.value as string;
expect(state).not.toBe('FAILED');
const errorMessage = session!.errorMessage as string | undefined;
if (errorMessage) {
expect(errorMessage).not.toContain('Browser not connected');
}
});
});

View File

@@ -1,146 +0,0 @@
import { describe, it, expect } from 'vitest';
import { MockAutomationLifecycleEmitter } from '../../../mocks/MockAutomationLifecycleEmitter';
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService';
import type { AutomationEvent } from 'apps/companion/main/automation/application/ports/IAutomationEventPublisher';
import type { OverlayAction } from 'apps/companion/main/automation/application/ports/IOverlaySyncPort';
type RendererOverlayState =
| { status: 'idle' }
| { status: 'starting'; actionId: string }
| { status: 'in-progress'; actionId: string }
| { status: 'completed'; actionId: string }
| { status: 'failed'; actionId: string };
class RecordingPublisher {
public events: AutomationEvent[] = [];
async publish(event: AutomationEvent): Promise<void> {
this.events.push(event);
}
}
function reduceEventsToRendererState(events: AutomationEvent[]): RendererOverlayState {
let state: RendererOverlayState = { status: 'idle' };
for (const ev of events) {
if (!ev.actionId) continue;
switch (ev.type) {
case 'modal-opened':
case 'panel-attached':
state = { status: 'starting', actionId: ev.actionId };
break;
case 'action-started':
state = { status: 'in-progress', actionId: ev.actionId };
break;
case 'action-complete':
state = { status: 'completed', actionId: ev.actionId };
break;
case 'action-failed':
case 'panel-missing':
state = { status: 'failed', actionId: ev.actionId };
break;
}
}
return state;
}
describe('renderer overlay lifecycle integration', () => {
it('tracks starting → in-progress → completed lifecycle for a hosted action', async () => {
const emitter = new MockAutomationLifecycleEmitter();
const publisher = new RecordingPublisher();
const svc = new OverlaySyncService({
lifecycleEmitter: emitter,
publisher,
logger: console as any,
defaultTimeoutMs: 2_000,
});
const action: OverlayAction = {
id: 'hosted-session',
label: 'Starting hosted session',
};
const ackPromise = svc.startAction(action);
expect(publisher.events[0]?.type).toBe('modal-opened');
expect(publisher.events[0]?.actionId).toBe('hosted-session');
await emitter.emit({
type: 'panel-attached',
actionId: 'hosted-session',
timestamp: Date.now(),
payload: { selector: '#gridpilot-overlay' },
});
await emitter.emit({
type: 'action-started',
actionId: 'hosted-session',
timestamp: Date.now(),
});
const ack = await ackPromise;
expect(ack.id).toBe('hosted-session');
expect(ack.status).toBe('confirmed');
await publisher.publish({
type: 'panel-attached',
actionId: 'hosted-session',
timestamp: Date.now(),
payload: { selector: '#gridpilot-overlay' },
} as AutomationEvent);
await publisher.publish({
type: 'action-started',
actionId: 'hosted-session',
timestamp: Date.now(),
} as AutomationEvent);
await publisher.publish({
type: 'action-complete',
actionId: 'hosted-session',
timestamp: Date.now(),
} as AutomationEvent);
const rendererState = reduceEventsToRendererState(publisher.events);
expect(rendererState.status).toBe('completed');
expect((rendererState as { actionId: string }).actionId).toBe('hosted-session');
});
it('ends in failed state when panel-missing is emitted', async () => {
const emitter = new MockAutomationLifecycleEmitter();
const publisher = new RecordingPublisher();
const svc = new OverlaySyncService({
lifecycleEmitter: emitter,
publisher,
logger: console as any,
defaultTimeoutMs: 200,
});
const action: OverlayAction = {
id: 'hosted-failure',
label: 'Hosted session failing',
};
void svc.startAction(action);
await publisher.publish({
type: 'panel-attached',
actionId: 'hosted-failure',
timestamp: Date.now(),
payload: { selector: '#gridpilot-overlay' },
} as AutomationEvent);
await publisher.publish({
type: 'action-failed',
actionId: 'hosted-failure',
timestamp: Date.now(),
payload: { reason: 'validation error' },
} as AutomationEvent);
const rendererState = reduceEventsToRendererState(publisher.events);
expect(rendererState.status).toBe('failed');
expect((rendererState as { actionId: string }).actionId).toBe('hosted-failure');
});
});

View File

@@ -1,26 +0,0 @@
import { describe, expect, test } from 'vitest'
import { MockAutomationLifecycleEmitter } from '../../../mocks/MockAutomationLifecycleEmitter'
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService'
describe('renderer overlay integration', () => {
test('renderer shows confirmed only after main acks confirmed', async () => {
const emitter = new MockAutomationLifecycleEmitter()
const publisher: { publish: (event: unknown) => Promise<void> } = { publish: async () => {} }
const svc = new OverlaySyncService({
lifecycleEmitter: emitter,
publisher,
logger: console as any,
})
// simulate renderer request
const promise = svc.startAction({ id: 'add-car', label: 'Adding...' })
// ack should be tentative until emitter emits action-started
await new Promise((r) => setTimeout(r, 20))
const tentative = await Promise.race([promise, Promise.resolve({ id: 'add-car', status: 'tentative' })])
// since no events yet, should still be pending promise; but we assert tentative fallback works after timeout in other tests
emitter.emit({ type: 'action-started', actionId: 'add-car', timestamp: Date.now() })
const ack = await promise
expect(ack.status).toBe('confirmed')
})
})