489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}); |