import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Page, Browser, BrowserContext, chromium } from 'playwright'; import { PlaywrightAutomationAdapter } from '../../../../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter'; import { HostedSessionConfig } from '../../../../packages/domain/entities/HostedSessionConfig'; import { BrowserModeConfig } from '../../../../packages/infrastructure/config/BrowserModeConfig'; import * as fs from 'fs'; import * as path from 'path'; /** * TDD Phase 1 (RED): Wizard Auto-Skip Detection & Synchronization Tests * * Tests for detecting wizard auto-skip behavior and synchronizing step counters * when iRacing wizard skips steps 8-10 with default configurations. */ describe('PlaywrightAutomationAdapter - Wizard Synchronization', () => { let adapter: PlaywrightAutomationAdapter; let mockPage: Page; let mockConfig: HostedSessionConfig; beforeEach(() => { mockPage = { locator: vi.fn(), // evaluate needs to return false for isPausedInBrowser check, // false for close request check, and empty object for selector validation evaluate: vi.fn().mockImplementation((fn: Function | string) => { const fnStr = typeof fn === 'function' ? fn.toString() : String(fn); // Check if this is the pause check if (fnStr.includes('__gridpilot_paused')) { return Promise.resolve(false); } // Check if this is the close request check if (fnStr.includes('__gridpilot_close_requested')) { return Promise.resolve(false); } // Default to returning empty results object for validation return Promise.resolve({}); }), } as any; mockConfig = { sessionName: 'Test Session', serverName: 'Test Server', password: 'test123', maxDrivers: 20, raceType: 'practice', } as HostedSessionConfig; adapter = new PlaywrightAutomationAdapter( { mode: 'real', headless: true, userDataDir: '/tmp/test' }, { log: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), } as any ); // Inject page for testing (adapter as any).page = mockPage; (adapter as any).connected = true; }); describe('detectCurrentWizardPage()', () => { it('should return "cars" when #set-cars container exists', async () => { // Mock locator to return 0 for all containers except #set-cars const mockLocatorFactory = (selector: string) => ({ count: vi.fn().mockResolvedValue(selector === '#set-cars' ? 1 : 0), }); vi.spyOn(mockPage, 'locator').mockImplementation(mockLocatorFactory as any); const result = await (adapter as any).detectCurrentWizardPage(); expect(result).toBe('cars'); expect(mockPage.locator).toHaveBeenCalledWith('#set-cars'); }); it('should return "track" when #set-track container exists', async () => { const mockLocatorFactory = (selector: string) => ({ count: vi.fn().mockResolvedValue(selector === '#set-track' ? 1 : 0), }); vi.spyOn(mockPage, 'locator').mockImplementation(mockLocatorFactory as any); const result = await (adapter as any).detectCurrentWizardPage(); expect(result).toBe('track'); }); it('should return "timeLimit" when #set-time-limit container exists', async () => { const mockLocatorFactory = (selector: string) => ({ count: vi.fn().mockResolvedValue(selector === '#set-time-limit' ? 1 : 0), }); vi.spyOn(mockPage, 'locator').mockImplementation(mockLocatorFactory as any); const result = await (adapter as any).detectCurrentWizardPage(); expect(result).toBe('timeLimit'); }); it('should return null when no step containers are found', async () => { const mockLocator = { count: vi.fn().mockResolvedValue(0), }; vi.spyOn(mockPage, 'locator').mockReturnValue(mockLocator as any); const result = await (adapter as any).detectCurrentWizardPage(); expect(result).toBeNull(); }); it('should return first matching container when multiple are present', async () => { // Simulate raceInformation (first in stepContainers) being present const mockLocatorFactory = (selector: string) => ({ count: vi.fn().mockResolvedValue(selector === '#set-session-information' ? 1 : 0), }); vi.spyOn(mockPage, 'locator').mockImplementation(mockLocatorFactory as any); const result = await (adapter as any).detectCurrentWizardPage(); expect(result).toBe('raceInformation'); }); it('should handle errors gracefully and return null', async () => { const mockLocator = { count: vi.fn().mockRejectedValue(new Error('Page not found')), }; vi.spyOn(mockPage, 'locator').mockReturnValue(mockLocator as any); const result = await (adapter as any).detectCurrentWizardPage(); expect(result).toBeNull(); }); describe('browser mode configuration updates', () => { let mockBrowser: Browser; let mockContext: BrowserContext; let mockPageWithClose: any; beforeEach(() => { // Create a new mock page with close method for these tests mockPageWithClose = { ...mockPage, setDefaultTimeout: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; // Mock browser and context mockBrowser = { newContext: vi.fn().mockResolvedValue({ newPage: vi.fn().mockResolvedValue(mockPageWithClose), close: vi.fn().mockResolvedValue(undefined), }), close: vi.fn().mockResolvedValue(undefined), } as any; mockContext = { newPage: vi.fn().mockResolvedValue(mockPageWithClose), close: vi.fn().mockResolvedValue(undefined), } as any; }); afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); }); it('should use updated browser mode configuration on each browser launch', async () => { // Mock the chromium module const mockLaunch = vi.fn() .mockResolvedValueOnce(mockBrowser) // First launch .mockResolvedValueOnce(mockBrowser); // Second launch vi.doMock('playwright-extra', () => ({ chromium: { launch: mockLaunch, use: vi.fn(), }, })); // Dynamic import to use the mocked module const playwrightExtra = await import('playwright-extra'); const adapter = new PlaywrightAutomationAdapter( { mode: 'mock', headless: true }, undefined ); // Create and inject browser mode loader const browserModeLoader = { load: vi.fn() .mockReturnValueOnce({ mode: 'headless' as const, source: 'file' as const }) // First call .mockReturnValueOnce({ mode: 'headed' as const, source: 'file' as const }), // Second call }; (adapter as any).browserModeLoader = browserModeLoader; // Override the connect method to use our mock const originalConnect = adapter.connect.bind(adapter); adapter.connect = async function(forceHeaded?: boolean) { // Simulate the connect logic without filesystem dependencies const currentConfig = (adapter as any).browserModeLoader.load(); const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode; await playwrightExtra.chromium.launch({ headless: effectiveMode === 'headless', }); (adapter as any).browser = mockBrowser; (adapter as any).context = await mockBrowser.newContext(); (adapter as any).page = mockPageWithClose; (adapter as any).connected = true; return { success: true }; }; // Act 1: Launch browser with initial config (headless) await adapter.connect(); // Assert 1: Should launch in headless mode expect(mockLaunch).toHaveBeenNthCalledWith(1, expect.objectContaining({ headless: true }) ); // Clean up first launch await adapter.disconnect(); // Act 2: Launch browser again - config should be re-read await adapter.connect(); // Assert 2: BUG - Should use updated config but uses cached value // This test will FAIL with the current implementation because it uses cached this.actualBrowserMode // Once fixed, it should launch in headed mode (headless: false) expect(mockLaunch).toHaveBeenNthCalledWith(2, expect.objectContaining({ headless: false // This will fail - bug uses cached value (true) }) ); // Clean up await adapter.disconnect(); }); it('should respect forceHeaded parameter regardless of config', async () => { // Mock the chromium module const mockLaunch = vi.fn().mockResolvedValue(mockBrowser); vi.doMock('playwright-extra', () => ({ chromium: { launch: mockLaunch, use: vi.fn(), }, })); // Dynamic import to use the mocked module const playwrightExtra = await import('playwright-extra'); const adapter = new PlaywrightAutomationAdapter( { mode: 'mock', headless: true }, undefined ); // Create and inject browser mode loader const browserModeLoader = { load: vi.fn().mockReturnValue({ mode: 'headless' as const, source: 'file' as const }), }; (adapter as any).browserModeLoader = browserModeLoader; // Override the connect method to use our mock adapter.connect = async function(forceHeaded?: boolean) { const currentConfig = (adapter as any).browserModeLoader.load(); const effectiveMode = forceHeaded ? 'headed' : currentConfig.mode; await playwrightExtra.chromium.launch({ headless: effectiveMode === 'headless', }); (adapter as any).browser = mockBrowser; (adapter as any).context = await mockBrowser.newContext(); (adapter as any).page = await (adapter as any).context.newPage(); (adapter as any).connected = true; return { success: true }; }; // Act: Launch browser with forceHeaded=true even though config is headless await adapter.connect(true); // Assert: Should launch in headed mode despite config expect(mockLaunch).toHaveBeenCalledWith( expect.objectContaining({ headless: false }) ); // Clean up await adapter.disconnect(); }); }); }); describe('synchronizeStepCounter()', () => { it('should return 0 when expected and current steps match', () => { const result = (adapter as any).synchronizeStepCounter(8, 'cars'); expect(result).toBe(0); }); it('should return 3 when wizard skipped from step 7 to step 11', () => { const result = (adapter as any).synchronizeStepCounter(8, 'track'); expect(result).toBe(3); }); it('should log warning when skip detected', () => { const loggerSpy = vi.spyOn((adapter as any).logger, 'warn'); (adapter as any).synchronizeStepCounter(8, 'track'); expect(loggerSpy).toHaveBeenCalledWith( 'Wizard auto-skip detected', expect.objectContaining({ expectedStep: 8, actualStep: 11, skipOffset: 3, skippedSteps: [8, 9, 10], }) ); }); it('should return skip offset for step 9 skipped to step 11', () => { const result = (adapter as any).synchronizeStepCounter(9, 'track'); expect(result).toBe(2); }); it('should return skip offset for step 10 skipped to step 11', () => { const result = (adapter as any).synchronizeStepCounter(10, 'track'); expect(result).toBe(1); }); it('should handle actualPage being null', () => { const result = (adapter as any).synchronizeStepCounter(8, null); expect(result).toBe(0); }); it('should handle page name not in STEP_TO_PAGE_MAP', () => { const result = (adapter as any).synchronizeStepCounter(8, 'unknown-page'); expect(result).toBe(0); }); it('should not log warning when steps are synchronized', () => { const loggerSpy = vi.spyOn((adapter as any).logger, 'warn'); (adapter as any).synchronizeStepCounter(11, 'track'); expect(loggerSpy).not.toHaveBeenCalled(); }); }); describe('executeStep() - Auto-Skip Integration', () => { beforeEach(() => { // Mock detectCurrentWizardPage to return 'track' (step 11) vi.spyOn(adapter as any, 'detectCurrentWizardPage').mockResolvedValue('track'); // Mock all the methods that executeStep calls to prevent actual execution vi.spyOn(adapter as any, 'updateOverlay').mockResolvedValue(undefined); vi.spyOn(adapter as any, 'saveProactiveDebugInfo').mockResolvedValue({}); vi.spyOn(adapter as any, 'dismissModals').mockResolvedValue(undefined); vi.spyOn(adapter as any, 'waitForWizardStep').mockResolvedValue(undefined); vi.spyOn(adapter as any, 'validatePageState').mockResolvedValue({ isOk: () => true, unwrap: () => ({ isValid: true }) }); vi.spyOn(adapter as any, 'checkWizardDismissed').mockResolvedValue(undefined); vi.spyOn(adapter as any, 'showOverlayComplete').mockResolvedValue(undefined); vi.spyOn(adapter as any, 'saveDebugInfo').mockResolvedValue({}); // Mock logger (adapter as any).logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }; }); it('should detect skip and return success for step 8 when wizard is on step 11', async () => { // Create StepId wrapper const stepId = { value: 8 } as any; const result = await (adapter as any).executeStep(stepId, {}); expect(result).toBeDefined(); expect((adapter as any).logger.info).toHaveBeenCalledWith( expect.stringContaining('Step 8 was auto-skipped'), expect.any(Object) ); }); it('should detect skip and return success for step 9 when wizard is on step 11', async () => { // Create StepId wrapper const stepId = { value: 9 } as any; const result = await (adapter as any).executeStep(stepId, {}); expect(result).toBeDefined(); expect((adapter as any).logger.info).toHaveBeenCalledWith( expect.stringContaining('Step 9 was auto-skipped'), expect.any(Object) ); }); it('should detect skip and return success for step 10 when wizard is on step 11', async () => { // Create StepId wrapper const stepId = { value: 10 } as any; const result = await (adapter as any).executeStep(stepId, {}); expect(result).toBeDefined(); expect((adapter as any).logger.info).toHaveBeenCalledWith( expect.stringContaining('Step 10 was auto-skipped'), expect.any(Object) ); }); it('should not skip when steps are synchronized', async () => { // Mock detectCurrentWizardPage to return 'cars' (step 8) vi.spyOn(adapter as any, 'detectCurrentWizardPage').mockResolvedValue('cars'); const stepId = { value: 8 } as any; const result = await (adapter as any).executeStep(stepId, {}); expect((adapter as any).logger.info).not.toHaveBeenCalledWith( expect.stringContaining('was auto-skipped'), expect.any(Object) ); }); it('should handle detectCurrentWizardPage returning null', async () => { vi.spyOn(adapter as any, 'detectCurrentWizardPage').mockResolvedValue(null); const stepId = { value: 8 } as any; const result = await (adapter as any).executeStep(stepId, {}); expect((adapter as any).logger.info).not.toHaveBeenCalledWith( expect.stringContaining('was auto-skipped'), expect.any(Object) ); }); it('should handle skip detection errors gracefully', async () => { vi.spyOn(adapter as any, 'detectCurrentWizardPage').mockRejectedValue( new Error('Detection failed') ); const stepId = { value: 8 } as any; const result = await (adapter as any).executeStep(stepId, {}); // Should still attempt to execute the step even if detection fails expect(result).toBeDefined(); }); }); describe('Edge Cases', () => { it('should handle step number outside STEP_TO_PAGE_MAP range', () => { const result = (adapter as any).synchronizeStepCounter(99, 'track'); expect(result).toBe(0); }); it('should handle negative step numbers', () => { // Negative step numbers are out of range, so synchronization logic // will calculate skip offset based on invalid step mapping const result = (adapter as any).synchronizeStepCounter(-1, 'track'); // Since -1 is not in STEP_TO_PAGE_MAP and track is step 11, // the result will be non-zero if the implementation doesn't guard against negatives expect(result).toBeGreaterThanOrEqual(0); }); it('should handle empty page name', () => { const result = (adapter as any).synchronizeStepCounter(8, ''); expect(result).toBe(0); }); }); });