Refactor infra tests, clean E2E step suites, and fix TS in tests

This commit is contained in:
2025-11-30 10:58:49 +01:00
parent af14526ae2
commit f8a1fbeb50
43 changed files with 883 additions and 2159 deletions

View File

@@ -0,0 +1,118 @@
/**
* Integration tests for FixtureServer and PlaywrightAutomationAdapter wiring.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { FixtureServer, getAllStepFixtureMappings, PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation';
declare const getComputedStyle: any;
declare const document: any;
describe('FixtureServer integration', () => {
let server: FixtureServer;
let adapter: PlaywrightAutomationAdapter;
let baseUrl: string;
beforeAll(async () => {
server = new FixtureServer();
const serverInfo = await server.start();
baseUrl = serverInfo.url;
adapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 5000,
baseUrl,
});
const connectResult = await adapter.connect();
expect(connectResult.success).toBe(true);
});
afterAll(async () => {
await adapter.disconnect();
await server.stop();
});
describe('FixtureServer', () => {
it('reports running state after start', () => {
expect(server.isRunning()).toBe(true);
});
it('exposes mappings for steps 1 through 18', () => {
const mappings = getAllStepFixtureMappings();
const stepNumbers = Object.keys(mappings).map(Number).sort((a, b) => a - b);
expect(stepNumbers[0]).toBe(1);
expect(stepNumbers[stepNumbers.length - 1]).toBe(18);
expect(stepNumbers).toHaveLength(18);
});
it('serves all mapped fixtures over HTTP', async () => {
const mappings = getAllStepFixtureMappings();
const stepNumbers = Object.keys(mappings).map(Number);
for (const stepNumber of stepNumbers) {
const url = server.getFixtureUrl(stepNumber);
const result = await adapter.navigateToPage(url);
expect(result.success).toBe(true);
}
});
it('serves CSS assets for a step fixture', async () => {
const page = adapter.getPage();
expect(page).not.toBeNull();
await adapter.navigateToPage(server.getFixtureUrl(2));
const cssLoaded = await page!.evaluate(() => {
const styles = getComputedStyle(document.body);
return styles.backgroundColor !== '';
});
expect(cssLoaded).toBe(true);
});
it('returns 404 for non-existent files', async () => {
const page = adapter.getPage();
expect(page).not.toBeNull();
const response = await page!.goto(`${baseUrl}/non-existent-file.html`);
expect(response?.status()).toBe(404);
});
});
describe('Error handling', () => {
it('returns error when browser is not connected', async () => {
const disconnectedAdapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 1000,
});
const navResult = await disconnectedAdapter.navigateToPage('http://localhost:9999');
expect(navResult.success).toBe(false);
expect(navResult.error).toBe('Browser not connected');
const fillResult = await disconnectedAdapter.fillFormField('test', 'value');
expect(fillResult.success).toBe(false);
expect(fillResult.error).toBe('Browser not connected');
const clickResult = await disconnectedAdapter.clickElement('test');
expect(clickResult.success).toBe(false);
expect(clickResult.error).toBe('Browser not connected');
});
it('reports connected state correctly', async () => {
expect(adapter.isConnected()).toBe(true);
const newAdapter = new PlaywrightAutomationAdapter({ headless: true });
expect(newAdapter.isConnected()).toBe(false);
await newAdapter.connect();
expect(newAdapter.isConnected()).toBe(true);
await newAdapter.disconnect();
expect(newAdapter.isConnected()).toBe(false);
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MockBrowserAutomationAdapter } from '../../../packages/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { MockBrowserAutomationAdapter } from 'packages/infrastructure/adapters/automation';
import { StepId } from 'packages/domain/value-objects/StepId';
describe('MockBrowserAutomationAdapter Integration Tests', () => {
let adapter: MockBrowserAutomationAdapter;

View File

@@ -1,249 +0,0 @@
/**
* Integration tests for Playwright adapter step 17 checkout flow with confirmation callback.
* Tests the pause-for-confirmation mechanism before clicking checkout button.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { FixtureServer, PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation';
import { StepId } from '../../../packages/domain/value-objects/StepId';
import { CheckoutConfirmation } from '../../../packages/domain/value-objects/CheckoutConfirmation';
import { CheckoutPrice } from '../../../packages/domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../../packages/domain/value-objects/CheckoutState';
describe('Playwright Step 17 Checkout Flow with Confirmation', () => {
let server: FixtureServer;
let adapter: PlaywrightAutomationAdapter;
let baseUrl: string;
beforeAll(async () => {
server = new FixtureServer();
const serverInfo = await server.start();
baseUrl = serverInfo.url;
adapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 5000,
baseUrl,
mode: 'mock',
});
const connectResult = await adapter.connect();
expect(connectResult.success).toBe(true);
});
afterAll(async () => {
await adapter.disconnect();
await server.stop();
});
beforeEach(async () => {
await adapter.navigateToPage(server.getFixtureUrl(17));
// Clear any previous callback
adapter.setCheckoutConfirmationCallback(undefined);
});
describe('Checkout Confirmation Callback Injection', () => {
it('should accept and store checkout confirmation callback', () => {
const mockCallback = vi.fn();
// Should not throw
expect(() => {
adapter.setCheckoutConfirmationCallback(mockCallback);
}).not.toThrow();
});
it('should allow clearing the callback by passing undefined', () => {
const mockCallback = vi.fn();
adapter.setCheckoutConfirmationCallback(mockCallback);
// Should not throw when clearing
expect(() => {
adapter.setCheckoutConfirmationCallback(undefined);
}).not.toThrow();
});
});
describe('Step 17 Execution with Confirmation Flow', () => {
it('should extract checkout info before requesting confirmation', async () => {
const mockCallback = vi.fn().mockResolvedValue(
CheckoutConfirmation.create('confirmed')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {});
expect(result.success).toBe(true);
expect(mockCallback).toHaveBeenCalledTimes(1);
// Verify callback was called with price and state
const callArgs = mockCallback.mock.calls[0];
expect(callArgs).toHaveLength(2);
const [price, state] = callArgs;
expect(price).toBeInstanceOf(CheckoutPrice);
expect(state).toBeInstanceOf(CheckoutState);
});
it('should show "Awaiting confirmation..." overlay before callback', async () => {
const mockCallback = vi.fn().mockImplementation(async () => {
const page = adapter.getPage()!;
const overlayText = await page.locator('#gridpilot-action').textContent();
expect(overlayText).toContain('Awaiting confirmation');
return CheckoutConfirmation.create('confirmed');
});
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
await adapter.executeStep(stepId, {});
expect(mockCallback).toHaveBeenCalled();
});
it('should treat "confirmed" checkout confirmation as a successful step 17 execution', async () => {
const mockCallback = vi.fn().mockResolvedValue(
CheckoutConfirmation.create('confirmed')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {});
expect(result.success).toBe(true);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should NOT click checkout button if confirmation is "cancelled"', async () => {
const mockCallback = vi.fn().mockResolvedValue(
CheckoutConfirmation.create('cancelled')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {});
expect(result.success).toBe(false);
expect(result.error).toContain('cancelled');
expect(mockCallback).toHaveBeenCalled();
});
it('should NOT click checkout button if confirmation is "timeout"', async () => {
const mockCallback = vi.fn().mockResolvedValue(
CheckoutConfirmation.create('timeout')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {});
expect(result.success).toBe(false);
expect(result.error).toContain('timeout');
expect(mockCallback).toHaveBeenCalled();
});
it('should show success overlay after confirmed checkout', async () => {
const mockCallback = vi.fn().mockResolvedValue(
CheckoutConfirmation.create('confirmed')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
await adapter.executeStep(stepId, {});
// Check for success overlay
const page = adapter.getPage()!;
const overlayExists = await page.locator('#gridpilot-overlay').count();
expect(overlayExists).toBeGreaterThan(0);
});
it('should execute step normally if no callback is set', async () => {
// No callback set - should execute without confirmation
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {});
// Should succeed without asking for confirmation
expect(result.success).toBe(true);
});
it('should handle callback errors gracefully', async () => {
const mockCallback = vi.fn().mockRejectedValue(
new Error('Callback failed')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(mockCallback).toHaveBeenCalled();
});
it('should always pass a CheckoutPrice instance to the confirmation callback, even when no DOM price is available', async () => {
let capturedPrice: CheckoutPrice | null = null;
const mockCallback = vi.fn().mockImplementation(async (price: CheckoutPrice) => {
capturedPrice = price;
return CheckoutConfirmation.create('confirmed');
});
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
await adapter.executeStep(stepId, {});
expect(capturedPrice).not.toBeNull();
expect(capturedPrice).toBeInstanceOf(CheckoutPrice);
// Price may be extracted from DOM or fall back to a neutral default (e.g. $0.00).
const display = capturedPrice!.toDisplayString();
expect(display).toMatch(/^\$\d+\.\d{2}$/);
});
it('should pass correct state from CheckoutState validation to callback', async () => {
let capturedState: CheckoutState | null = null;
const mockCallback = vi.fn().mockImplementation(
async (_price: CheckoutPrice, state: CheckoutState) => {
capturedState = state;
return CheckoutConfirmation.create('confirmed');
}
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
await adapter.executeStep(stepId, {});
expect(capturedState).not.toBeNull();
expect(capturedState).toBeInstanceOf(CheckoutState);
// State should indicate whether checkout is ready (method, not property)
expect(typeof capturedState!.isReady()).toBe('boolean');
});
});
describe('Step 17 with Track State Configuration', () => {
it('should use provided trackState value without failing and still invoke the confirmation callback', async () => {
const mockCallback = vi.fn().mockResolvedValue(
CheckoutConfirmation.create('confirmed')
);
adapter.setCheckoutConfirmationCallback(mockCallback);
const stepId = StepId.create(17);
const result = await adapter.executeStep(stepId, {
trackState: 'moderately-low',
});
expect(result.success).toBe(true);
expect(mockCallback).toHaveBeenCalled();
});
});
});

View File

@@ -1,196 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { JSDOM } from 'jsdom';
import { IRACING_SELECTORS } from '../../../packages/infrastructure/adapters/automation/IRacingSelectors';
/**
* Selector Verification Tests
*
* These tests load the real HTML dumps from iRacing and verify that our selectors
* correctly find the expected elements. This ensures our automation is robust
* against the actual DOM structure.
*/
describe('Selector Verification against HTML Dumps', () => {
const dumpsDir = path.join(process.cwd(), 'html-dumps/iracing-hosted-sessions');
let dumps: Record<string, Document> = {};
// Helper to load and parse HTML dump
const loadDump = (filename: string): Document => {
const filePath = path.join(dumpsDir, filename);
if (!fs.existsSync(filePath)) {
throw new Error(`Dump file not found: ${filePath}`);
}
const html = fs.readFileSync(filePath, 'utf-8');
const dom = new JSDOM(html);
return dom.window.document;
};
beforeAll(() => {
// Load critical dumps
try {
dumps['hosted'] = loadDump('01-hosted-racing.html');
dumps['create'] = loadDump('02-create-a-race.html');
dumps['raceInfo'] = loadDump('03-race-information.html');
dumps['cars'] = loadDump('08-set-cars.html');
dumps['addCar'] = loadDump('09-add-a-car.html');
dumps['track'] = loadDump('11-set-track.html');
dumps['addTrack'] = loadDump('12-add-a-track.html');
dumps['checkout'] = loadDump('18-track-conditions.html'); // Assuming checkout button is here
dumps['step3'] = loadDump('03-race-information.html');
} catch (e) {
console.warn('Could not load some HTML dumps. Tests may be skipped.', e);
}
});
// Helper to check if selector finds elements
const checkSelector = (doc: Document, selector: string, description: string) => {
// Handle Playwright-specific pseudo-classes that JSDOM doesn't support
// We'll strip them for basic verification or use a simplified version
const cleanSelector = selector
.replace(/:has-text\("[^"]+"\)/g, '')
.replace(/:has\([^)]+\)/g, '')
.replace(/:not\([^)]+\)/g, '');
// If selector became empty or too complex, we might need manual verification logic
if (!cleanSelector || cleanSelector === selector) {
// Try standard querySelector
try {
const element = doc.querySelector(selector);
expect(element, `Selector "${selector}" for ${description} should find an element`).not.toBeNull();
} catch (e) {
// JSDOM might fail on complex CSS selectors that Playwright supports
// In that case, we skip or log a warning
console.warn(`JSDOM could not parse selector "${selector}": ${e}`);
}
} else {
// For complex selectors, we can try to find the base element and then check text/children manually
// This is a simplified check
try {
const elements = doc.querySelectorAll(cleanSelector);
expect(elements.length, `Base selector "${cleanSelector}" for ${description} should find elements`).toBeGreaterThan(0);
} catch (e) {
console.warn(`JSDOM could not parse cleaned selector "${cleanSelector}": ${e}`);
}
}
};
describe('Hosted Racing Page (Step 2)', () => {
it('should find "Create a Race" button', () => {
if (!dumps['hosted']) return;
// The selector uses :has-text which JSDOM doesn't support directly
// We'll verify the button exists and has the text
const buttons = Array.from(dumps['hosted'].querySelectorAll('button'));
const createBtn = buttons.find(b => b.textContent?.includes('Create a Race') || b.getAttribute('aria-label') === 'Create a Race');
expect(createBtn).toBeDefined();
});
});
describe('Wizard Modal', () => {
it('should find the wizard modal container', () => {
if (!dumps['raceInfo']) return;
// The modal is present in step 3 (race information), not in step 2 (create-a-race)
// IRACING_SELECTORS.wizard.modal
// '#create-race-modal, [role="dialog"], .modal.fade.in'
const modal = dumps['raceInfo'].querySelector('#create-race-modal') ||
dumps['raceInfo'].querySelector('.modal.fade.in');
expect(modal).not.toBeNull();
});
it('should find wizard step containers', () => {
if (!dumps['raceInfo']) return;
// IRACING_SELECTORS.wizard.stepContainers.raceInformation
const container = dumps['raceInfo'].querySelector(IRACING_SELECTORS.wizard.stepContainers.raceInformation);
expect(container).not.toBeNull();
});
});
describe('Form Fields', () => {
it('should find session name input', () => {
if (!dumps['raceInfo']) return;
// IRACING_SELECTORS.steps.sessionName
// This is a complex selector, let's check the input exists
const input = dumps['raceInfo'].querySelector('input[name="sessionName"]') ||
dumps['raceInfo'].querySelector('input.form-control');
expect(input).not.toBeNull();
});
it('should find password input', () => {
if (!dumps['step3']) return;
// IRACING_SELECTORS.steps.password
// Based on debug output, password input might be one of the chakra-inputs
// But none have type="password". This suggests iRacing might be using a text input for password
// or the dump doesn't capture the password field correctly (e.g. dynamic rendering).
// However, we see many text inputs. Let's try to find one that looks like a password field
// or just verify ANY input exists if we can't be specific.
// For now, let's check if we can find the input that corresponds to the password field
// In the absence of a clear password field, we'll check for the presence of ANY input
// that could be the password field (e.g. second form group)
const inputs = dumps['step3'].querySelectorAll('input.chakra-input');
expect(inputs.length).toBeGreaterThan(0);
// If we can't find a specific password input, we might need to rely on the fact that
// there are inputs present and the automation script uses a more complex selector
// that might match one of them in a real browser environment (e.g. by order).
});
it('should find description textarea', () => {
if (!dumps['step3']) return;
// IRACING_SELECTORS.steps.description
const textarea = dumps['step3'].querySelector('textarea.form-control');
expect(textarea).not.toBeNull();
});
});
describe('Cars Page', () => {
it('should find Add Car button', () => {
if (!dumps['cars']) return;
// IRACING_SELECTORS.steps.addCarButton
// Check for button with "Add" text or icon
const buttons = Array.from(dumps['cars'].querySelectorAll('a.btn, button'));
const addBtn = buttons.find(b => b.textContent?.includes('Add') || b.querySelector('.icon-plus'));
expect(addBtn).toBeDefined();
});
it('should find Car Search input in modal', () => {
if (!dumps['addCar']) return;
// IRACING_SELECTORS.steps.carSearch
const input = dumps['addCar'].querySelector('input[placeholder*="Search"]');
expect(input).not.toBeNull();
});
});
describe('Tracks Page', () => {
it('should find Add Track button', () => {
if (!dumps['track']) return;
// IRACING_SELECTORS.steps.addTrackButton
const buttons = Array.from(dumps['track'].querySelectorAll('a.btn, button'));
const addBtn = buttons.find(b => b.textContent?.includes('Add') || b.querySelector('.icon-plus'));
expect(addBtn).toBeDefined();
});
});
describe('Checkout/Payment', () => {
it('should find checkout button', () => {
if (!dumps['checkout']) return;
// IRACING_SELECTORS.BLOCKED_SELECTORS.checkout
// Look for button with "Check Out" or cart icon
const buttons = Array.from(dumps['checkout'].querySelectorAll('a.btn, button'));
const checkoutBtn = buttons.find(b =>
b.textContent?.includes('Check Out') ||
b.querySelector('.icon-cart') ||
b.getAttribute('data-testid')?.includes('checkout')
);
// Note: It might not be present if not fully configured, but we check if we can find it if it were
// In the dump 18-track-conditions.html, it might be the "Buy Now" or similar
if (checkoutBtn) {
expect(checkoutBtn).toBeDefined();
} else {
console.log('Checkout button not found in dump 18, might be in a different state');
}
});
});
});

View File

@@ -1,429 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import * as path from 'path';
import { CheckAuthenticationUseCase } from '../../../packages/application/use-cases/CheckAuthenticationUseCase';
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
import { Result } from '../../../packages/shared/result/Result';
import { PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation';
const TEST_USER_DATA_DIR = path.join(__dirname, '../../../test-browser-data');
const SESSION_FILE_PATH = path.join(TEST_USER_DATA_DIR, 'session-state.json');
interface SessionData {
cookies: Array<{ name: string; value: string; domain: string; path: string; expires: number }>;
expiry: string | null;
}
describe('Session Validation After Startup', () => {
beforeEach(async () => {
// Ensure test directory exists
try {
await fs.mkdir(TEST_USER_DATA_DIR, { recursive: true });
} catch {
// Directory already exists
}
// Clean up session file if it exists
try {
await fs.unlink(SESSION_FILE_PATH);
} catch {
// File doesn't exist, that's fine
}
});
afterEach(async () => {
try {
await fs.unlink(SESSION_FILE_PATH);
} catch {
// Cleanup best effort
}
});
describe('Initial check on app startup', () => {
it('should detect valid session on startup', async () => {
const validSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'valid-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() + 3600000,
},
],
expiry: new Date(Date.now() + 3600000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
});
it('should detect expired session on startup', async () => {
const expiredSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'expired-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() - 3600000,
},
],
expiry: new Date(Date.now() - 3600000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(expiredSessionData, null, 2));
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toBe(AuthenticationState.EXPIRED);
});
it('should handle missing session file on startup', async () => {
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toBe(AuthenticationState.UNKNOWN);
});
});
describe('Session expiry during runtime', () => {
it('should transition from AUTHENTICATED to EXPIRED after time passes', async () => {
// Start with a session that expires in 10 minutes (beyond 5-minute buffer)
const initialExpiry = Date.now() + (10 * 60 * 1000);
const shortLivedSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'short-lived-token',
domain: '.iracing.com',
path: '/',
expires: initialExpiry,
},
],
expiry: new Date(initialExpiry).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(shortLivedSessionData, null, 2));
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const firstCheck = await useCase.execute();
expect(firstCheck.value).toBe(AuthenticationState.AUTHENTICATED);
// Now update the session file to have an expiry in the past
const expiredSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'short-lived-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() - 1000,
},
],
expiry: new Date(Date.now() - 1000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(expiredSessionData, null, 2));
const secondCheck = await useCase.execute();
expect(secondCheck.value).toBe(AuthenticationState.EXPIRED);
});
it('should maintain AUTHENTICATED state when session is still valid', async () => {
const longLivedSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'long-lived-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() + 3600000,
},
],
expiry: new Date(Date.now() + 3600000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(longLivedSessionData, null, 2));
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const firstCheck = await useCase.execute();
expect(firstCheck.value).toBe(AuthenticationState.AUTHENTICATED);
await new Promise(resolve => setTimeout(resolve, 100));
const secondCheck = await useCase.execute();
expect(secondCheck.value).toBe(AuthenticationState.AUTHENTICATED);
});
});
describe('Browser connection before auth check', () => {
it('should establish browser connection then validate auth', async () => {
const validSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'valid-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() + 3600000,
},
],
expiry: new Date(Date.now() + 3600000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
const browserAdapter = createMockBrowserAdapter();
await browserAdapter.initialize();
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(browserAdapter.isInitialized()).toBe(true);
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
});
it('should handle auth check when browser connection fails', async () => {
const validSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'valid-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() + 3600000,
},
],
expiry: new Date(Date.now() + 3600000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
const browserAdapter = createMockBrowserAdapter();
browserAdapter.setConnectionFailure(true);
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
});
});
describe('Authentication detection logic', () => {
it('should consider page authenticated when both hasAuthUI=true AND hasLoginUI=true', async () => {
// This tests the core bug: when authenticated UI is detected alongside login UI,
// authentication should be considered VALID because authenticated UI takes precedence
// Mock scenario: Dashboard visible (authenticated) but profile menu contains "Log in" text
const mockAdapter = {
page: {
locator: vi.fn(),
},
logger: undefined,
};
// Setup: Both authenticated UI and login UI detected
let callCount = 0;
mockAdapter.page.locator.mockImplementation((selector: string) => {
callCount++;
// First call: checkForLoginUI - 'text="You are not logged in"'
if (callCount === 1) {
return {
first: () => ({
isVisible: () => Promise.resolve(false),
}),
};
}
// Second call: checkForLoginUI - 'button:has-text("Log in")'
if (callCount === 2) {
return {
first: () => ({
isVisible: () => Promise.resolve(true), // FALSE POSITIVE from profile menu
}),
};
}
// Third call: authenticated UI - 'button:has-text("Create a Race")'
if (callCount === 3) {
return {
first: () => ({
isVisible: () => Promise.resolve(true), // Authenticated UI detected
}),
};
}
return {
first: () => ({
isVisible: () => Promise.resolve(false),
}),
};
}) as any;
// Simulate the logic from PlaywrightAutomationAdapter.verifyPageAuthentication
const hasLoginUI = true; // False positive from profile menu
const hasAuthUI = true; // Real authenticated UI detected
// CURRENT BUGGY LOGIC: const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
const currentLogic = !hasLoginUI && (hasAuthUI || !hasLoginUI);
// EXPECTED CORRECT LOGIC: const pageAuthenticated = hasAuthUI || !hasLoginUI;
const correctLogic = hasAuthUI || !hasLoginUI;
expect(currentLogic).toBe(false); // Current buggy behavior
expect(correctLogic).toBe(true); // Expected correct behavior
});
it('should consider page authenticated when hasAuthUI=true even if hasLoginUI=true', async () => {
// When authenticated UI is present, it should override any login UI detection
const hasLoginUI = true;
const hasAuthUI = true;
// Buggy logic
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
// This fails: even though authenticated UI is detected, the result is false
// because hasLoginUI=true makes the first condition fail
expect(pageAuthenticated).toBe(false); // BUG: Should be true
});
it('should consider page authenticated when hasAuthUI=true and hasLoginUI=false', async () => {
// When authenticated UI is present and no login UI, clearly authenticated
const hasLoginUI = false;
const hasAuthUI = true;
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
expect(pageAuthenticated).toBe(true); // This works correctly
});
it('should consider page authenticated when hasAuthUI=false and hasLoginUI=false', async () => {
// No login UI and no explicit auth UI - assume authenticated (no login required)
const hasLoginUI = false;
const hasAuthUI = false;
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
expect(pageAuthenticated).toBe(true); // This works correctly
});
it('should consider page unauthenticated when hasAuthUI=false and hasLoginUI=true', async () => {
// Clear login UI with no authenticated UI - definitely not authenticated
const hasLoginUI = true;
const hasAuthUI = false;
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
expect(pageAuthenticated).toBe(false); // This works correctly
});
});
describe('BDD Scenarios', () => {
it('Scenario: App starts with valid session', async () => {
const validSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'valid-session-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() + 7200000,
},
],
expiry: new Date(Date.now() + 7200000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
});
it('Scenario: App starts with expired session', async () => {
const expiredSessionData: SessionData = {
cookies: [
{
name: 'irsso_membersv2',
value: 'expired-session-token',
domain: '.iracing.com',
path: '/',
expires: Date.now() - 7200000,
},
],
expiry: new Date(Date.now() - 7200000).toISOString(),
};
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(expiredSessionData, null, 2));
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.value).toBe(AuthenticationState.EXPIRED);
});
it('Scenario: App starts without session', async () => {
const authService = createRealAuthenticationService();
const useCase = new CheckAuthenticationUseCase(authService);
const result = await useCase.execute();
expect(result.value).toBe(AuthenticationState.UNKNOWN);
});
});
});
function createRealAuthenticationService() {
// Create adapter with test-specific user data directory
const adapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 5000,
mode: 'real',
userDataDir: TEST_USER_DATA_DIR,
});
return adapter;
}
function createMockBrowserAdapter() {
// Simple mock that tracks initialization state
let initialized = false;
let shouldFailConnection = false;
return {
initialize: async () => {
if (shouldFailConnection) {
throw new Error('Mock connection failure');
}
initialized = true;
},
isInitialized: () => initialized,
setConnectionFailure: (fail: boolean) => {
shouldFailConnection = fail;
},
};
}

View File

@@ -1,6 +1,5 @@
import { jest } from '@jest/globals'
import { MockAutomationLifecycleEmitter } from '../mocks/MockAutomationLifecycleEmitter'
import { PlaywrightAutomationAdapter } from '../../../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter'
import { describe, test, expect } from 'vitest'
import { PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation'
describe('CarsFlow integration', () => {
test('adapter emits panel-attached then action-started then action-complete for performAddCar', async () => {
@@ -20,8 +19,12 @@ describe('CarsFlow integration', () => {
// call attachPanel which emits panel-attached and then action-started
await adapter.attachPanel(mockPage, 'add-car')
// simulate complete event
await adapter.emitLifecycle?.({ type: 'action-complete', actionId: 'add-car', timestamp: Date.now() } as any)
// simulate complete event via internal lifecycle emitter
await (adapter as any).emitLifecycle({
type: 'action-complete',
actionId: 'add-car',
timestamp: Date.now(),
} as any)
const types = received.map(r => r.type)
expect(types.indexOf('panel-attached')).toBeGreaterThanOrEqual(0)

View File

@@ -1,6 +1,6 @@
import { jest } from '@jest/globals'
import { MockAutomationLifecycleEmitter } from '../mocks/MockAutomationLifecycleEmitter'
import { OverlaySyncService } from '../../packages/application/services/OverlaySyncService'
import { describe, expect, test } from 'vitest'
import { MockAutomationLifecycleEmitter } from '../../../mocks/MockAutomationLifecycleEmitter'
import { OverlaySyncService } from 'packages/application/services/OverlaySyncService'
describe('renderer overlay integration', () => {
test('renderer shows confirmed only after main acks confirmed', async () => {

View File

@@ -1,474 +0,0 @@
/**
* Integration tests for PlaywrightAutomationAdapter using mock HTML fixtures.
*
* These tests verify that the browser automation adapter correctly interacts
* with the mock fixtures served by FixtureServer.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { FixtureServer, getAllStepFixtureMappings, PlaywrightAutomationAdapter } from 'packages/infrastructure/adapters/automation';
import { StepId } from '../../packages/domain/value-objects/StepId';
describe('Playwright Browser Automation', () => {
let server: FixtureServer;
let adapter: PlaywrightAutomationAdapter;
let baseUrl: string;
beforeAll(async () => {
server = new FixtureServer();
const serverInfo = await server.start();
baseUrl = serverInfo.url;
adapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 5000,
baseUrl,
});
const connectResult = await adapter.connect();
expect(connectResult.success).toBe(true);
});
afterAll(async () => {
await adapter.disconnect();
await server.stop();
});
describe('FixtureServer Tests', () => {
it('should start and report running state', () => {
expect(server.isRunning()).toBe(true);
});
it('should serve the root URL with step 2 fixture', async () => {
const result = await adapter.navigateToPage(`${baseUrl}/`);
expect(result.success).toBe(true);
const step = await adapter.getCurrentStep();
expect(step).toBe(2);
});
it('should serve all 16 step fixtures (steps 2-17)', async () => {
const mappings = getAllStepFixtureMappings();
const stepNumbers = Object.keys(mappings).map(Number);
expect(stepNumbers).toHaveLength(16);
expect(stepNumbers).toContain(2);
expect(stepNumbers).toContain(17);
for (const stepNum of stepNumbers) {
const url = server.getFixtureUrl(stepNum);
const result = await adapter.navigateToPage(url);
expect(result.success).toBe(true);
}
});
it('should serve CSS file correctly', async () => {
const page = adapter.getPage();
expect(page).not.toBeNull();
await adapter.navigateToPage(server.getFixtureUrl(2));
const cssLoaded = await page!.evaluate(() => {
const styles = getComputedStyle(document.body);
return styles.backgroundColor !== '';
});
expect(cssLoaded).toBe(true);
});
it('should return 404 for non-existent files', async () => {
const page = adapter.getPage();
expect(page).not.toBeNull();
const response = await page!.goto(`${baseUrl}/non-existent-file.html`);
expect(response?.status()).toBe(404);
});
});
describe('Step Detection Tests', () => {
beforeEach(async () => {
await adapter.navigateToPage(server.getFixtureUrl(2));
});
it('should detect current step via data-step attribute', async () => {
const step = await adapter.getCurrentStep();
expect(step).toBe(2);
});
it('should correctly identify step 3', async () => {
await adapter.navigateToPage(server.getFixtureUrl(3));
const step = await adapter.getCurrentStep();
expect(step).toBe(3);
});
it('should correctly identify step 17 (final step)', async () => {
await adapter.navigateToPage(server.getFixtureUrl(17));
const step = await adapter.getCurrentStep();
expect(step).toBe(17);
});
it('should detect step from each fixture file correctly', async () => {
// Note: Some fixture files have mismatched names vs data-step attributes
// This test verifies we can detect whatever step is in each file
const mappings = getAllStepFixtureMappings();
for (const stepNum of Object.keys(mappings).map(Number)) {
await adapter.navigateToPage(server.getFixtureUrl(stepNum));
const detectedStep = await adapter.getCurrentStep();
expect(detectedStep).toBeGreaterThanOrEqual(2);
expect(detectedStep).toBeLessThanOrEqual(17);
}
});
it('should wait for specific step to be visible', async () => {
await adapter.navigateToPage(server.getFixtureUrl(4));
await expect(adapter.waitForStep(4)).resolves.not.toThrow();
});
});
describe('Navigation Tests', () => {
beforeEach(async () => {
await adapter.navigateToPage(server.getFixtureUrl(2));
});
it('should click data-action="create" on step 2 to navigate to step 3', async () => {
const result = await adapter.clickAction('create');
expect(result.success).toBe(true);
await adapter.waitForStep(3);
const step = await adapter.getCurrentStep();
expect(step).toBe(3);
});
it('should click data-action="next" to navigate forward', async () => {
await adapter.navigateToPage(server.getFixtureUrl(3));
const result = await adapter.clickAction('next');
expect(result.success).toBe(true);
await adapter.waitForStep(4);
const step = await adapter.getCurrentStep();
expect(step).toBe(4);
});
it('should click data-action="back" to navigate backward', async () => {
await adapter.navigateToPage(server.getFixtureUrl(4));
const result = await adapter.clickAction('back');
expect(result.success).toBe(true);
await adapter.waitForStep(3);
const step = await adapter.getCurrentStep();
expect(step).toBe(3);
});
it('should fail gracefully when clicking non-existent action', async () => {
const result = await adapter.clickElement('[data-action="nonexistent"]');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('Form Field Tests', () => {
beforeEach(async () => {
await adapter.navigateToPage(server.getFixtureUrl(3));
});
it('should fill data-field text inputs', async () => {
const result = await adapter.fillField('sessionName', 'Test Session');
expect(result.success).toBe(true);
expect(result.fieldName).toBe('sessionName');
expect(result.value).toBe('Test Session');
const page = adapter.getPage()!;
const value = await page.inputValue('[data-field="sessionName"]');
expect(value).toBe('Test Session');
});
it('should fill password field', async () => {
const result = await adapter.fillField('password', 'secret123');
expect(result.success).toBe(true);
const page = adapter.getPage()!;
const value = await page.inputValue('[data-field="password"]');
expect(value).toBe('secret123');
});
it('should fill textarea field', async () => {
const result = await adapter.fillField('description', 'This is a test description');
expect(result.success).toBe(true);
const page = adapter.getPage()!;
const value = await page.inputValue('[data-field="description"]');
expect(value).toBe('This is a test description');
});
it('should select from data-dropdown elements', async () => {
await adapter.navigateToPage(server.getFixtureUrl(4));
await adapter.selectDropdown('region', 'eu-central');
const page = adapter.getPage()!;
const value = await page.inputValue('[data-dropdown="region"]');
expect(value).toBe('eu-central');
});
it('should toggle data-toggle checkboxes', async () => {
await adapter.navigateToPage(server.getFixtureUrl(4));
const page = adapter.getPage()!;
const initialState = await page.isChecked('[data-toggle="startNow"]');
await adapter.setToggle('startNow', !initialState);
const newState = await page.isChecked('[data-toggle="startNow"]');
expect(newState).toBe(!initialState);
});
it('should set data-slider range inputs', async () => {
await adapter.navigateToPage(server.getFixtureUrl(17));
await adapter.setSlider('rubberLevel', 75);
const page = adapter.getPage()!;
const value = await page.inputValue('[data-slider="rubberLevel"]');
expect(value).toBe('75');
});
it('should fail gracefully when filling non-existent field', async () => {
const result = await adapter.fillFormField('nonexistent', 'value');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('Modal Tests', () => {
beforeEach(async () => {
await adapter.navigateToPage(server.getFixtureUrl(7));
});
it('should detect modal presence via data-modal="true"', async () => {
const page = adapter.getPage()!;
const modalExists = await page.$('[data-modal="true"]');
expect(modalExists).not.toBeNull();
});
it('should wait for modal to be visible', async () => {
await expect(adapter.waitForModal()).resolves.not.toThrow();
});
it('should interact with modal content fields', async () => {
const result = await adapter.fillField('adminSearch', 'John');
expect(result.success).toBe(true);
const page = adapter.getPage()!;
const value = await page.inputValue('[data-field="adminSearch"]');
expect(value).toBe('John');
});
it('should close modal via data-action="confirm"', async () => {
const result = await adapter.clickAction('confirm');
expect(result.success).toBe(true);
await adapter.waitForStep(5);
const step = await adapter.getCurrentStep();
expect(step).toBe(5);
});
it('should close modal via data-action="cancel"', async () => {
await adapter.navigateToPage(server.getFixtureUrl(7));
const result = await adapter.clickAction('cancel');
expect(result.success).toBe(true);
await adapter.waitForStep(5);
const step = await adapter.getCurrentStep();
expect(step).toBe(5);
});
it('should handle modal via handleModal method with confirm action', async () => {
const stepId = StepId.create(6);
const result = await adapter.handleModal(stepId, 'confirm');
expect(result.success).toBe(true);
expect(result.action).toBe('confirm');
});
it('should select list items in modal via data-item', async () => {
await expect(adapter.selectListItem('admin-001')).resolves.not.toThrow();
});
});
describe('Full Flow Tests', () => {
beforeEach(async () => {
await adapter.navigateToPage(server.getFixtureUrl(2));
});
it('should navigate through steps 2 → 3 → 4', async () => {
expect(await adapter.getCurrentStep()).toBe(2);
await adapter.clickAction('create');
await adapter.waitForStep(3);
expect(await adapter.getCurrentStep()).toBe(3);
await adapter.clickAction('next');
await adapter.waitForStep(4);
expect(await adapter.getCurrentStep()).toBe(4);
});
it('should fill form fields and navigate through steps 3 → 4 → 5', async () => {
await adapter.navigateToPage(server.getFixtureUrl(3));
await adapter.fillField('sessionName', 'My Race Session');
await adapter.fillField('password', 'pass123');
await adapter.fillField('description', 'A test racing session');
await adapter.clickAction('next');
await adapter.waitForStep(4);
await adapter.selectDropdown('region', 'us-west');
await adapter.setToggle('startNow', true);
await adapter.clickAction('next');
await adapter.waitForStep(5);
expect(await adapter.getCurrentStep()).toBe(5);
});
it('should navigate backward through multiple steps', async () => {
await adapter.navigateToPage(server.getFixtureUrl(5));
expect(await adapter.getCurrentStep()).toBe(5);
await adapter.clickAction('back');
await adapter.waitForStep(4);
expect(await adapter.getCurrentStep()).toBe(4);
await adapter.clickAction('back');
await adapter.waitForStep(3);
expect(await adapter.getCurrentStep()).toBe(3);
});
it('should execute step 2 via executeStep method', async () => {
const stepId = StepId.create(2);
const result = await adapter.executeStep(stepId, {});
expect(result.success).toBe(true);
await adapter.waitForStep(3);
expect(await adapter.getCurrentStep()).toBe(3);
});
it('should execute step 3 with config via executeStep method', async () => {
await adapter.navigateToPage(server.getFixtureUrl(3));
const stepId = StepId.create(3);
const result = await adapter.executeStep(stepId, {
sessionName: 'Automated Session',
password: 'auto123',
description: 'Created by automation',
});
expect(result.success).toBe(true);
await adapter.waitForStep(4);
expect(await adapter.getCurrentStep()).toBe(4);
});
it('should execute step 4 with dropdown and toggle config', async () => {
await adapter.navigateToPage(server.getFixtureUrl(4));
const stepId = StepId.create(4);
const result = await adapter.executeStep(stepId, {
region: 'asia',
startNow: true,
});
expect(result.success).toBe(true);
await adapter.waitForStep(5);
expect(await adapter.getCurrentStep()).toBe(5);
});
});
describe('Error Handling Tests', () => {
it('should return error when browser not connected', async () => {
const disconnectedAdapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 1000,
});
const navResult = await disconnectedAdapter.navigateToPage('http://localhost:9999');
expect(navResult.success).toBe(false);
expect(navResult.error).toBe('Browser not connected');
const fillResult = await disconnectedAdapter.fillFormField('test', 'value');
expect(fillResult.success).toBe(false);
expect(fillResult.error).toBe('Browser not connected');
const clickResult = await disconnectedAdapter.clickElement('test');
expect(clickResult.success).toBe(false);
expect(clickResult.error).toBe('Browser not connected');
});
it('should handle timeout when waiting for non-existent element', async () => {
const shortTimeoutAdapter = new PlaywrightAutomationAdapter({
headless: true,
timeout: 100,
});
await shortTimeoutAdapter.connect();
await shortTimeoutAdapter.navigateToPage(server.getFixtureUrl(2));
const result = await shortTimeoutAdapter.waitForElement('[data-step="99"]', 100);
expect(result.success).toBe(false);
expect(result.error).toContain('Timeout');
await shortTimeoutAdapter.disconnect();
});
it('should report connected state correctly', async () => {
expect(adapter.isConnected()).toBe(true);
const newAdapter = new PlaywrightAutomationAdapter({ headless: true });
expect(newAdapter.isConnected()).toBe(false);
await newAdapter.connect();
expect(newAdapter.isConnected()).toBe(true);
await newAdapter.disconnect();
expect(newAdapter.isConnected()).toBe(false);
});
});
describe('Indicator and List Tests', () => {
it('should detect step indicator element', async () => {
await adapter.navigateToPage(server.getFixtureUrl(3));
const page = adapter.getPage()!;
const indicator = await page.$('[data-indicator="race-information"]');
expect(indicator).not.toBeNull();
});
it('should detect list container', async () => {
await adapter.navigateToPage(server.getFixtureUrl(8));
const page = adapter.getPage()!;
const list = await page.$('[data-list="cars"]');
expect(list).not.toBeNull();
});
it('should detect modal trigger button', async () => {
await adapter.navigateToPage(server.getFixtureUrl(8));
const page = adapter.getPage()!;
const trigger = await page.$('[data-modal-trigger="car"]');
expect(trigger).not.toBeNull();
});
it('should click modal trigger and navigate to modal step', async () => {
await adapter.navigateToPage(server.getFixtureUrl(8));
await adapter.openModalTrigger('car');
// The modal trigger navigates to step-10-add-car.html which has data-step="9"
await adapter.waitForStep(9);
expect(await adapter.getCurrentStep()).toBe(9);
});
});
});